first commit

This commit is contained in:
2025-07-18 16:20:14 +07:00
commit 98af45c018
16382 changed files with 3148096 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
<?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\Bridge\Twig;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Translation\LocaleSwitcher;
/**
* Exposes some Symfony parameters and services as an "app" global variable.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class AppVariable
{
private TokenStorageInterface $tokenStorage;
private RequestStack $requestStack;
private string $environment;
private bool $debug;
private LocaleSwitcher $localeSwitcher;
private array $enabledLocales;
/**
* @return void
*/
public function setTokenStorage(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
/**
* @return void
*/
public function setRequestStack(RequestStack $requestStack)
{
$this->requestStack = $requestStack;
}
/**
* @return void
*/
public function setEnvironment(string $environment)
{
$this->environment = $environment;
}
/**
* @return void
*/
public function setDebug(bool $debug)
{
$this->debug = $debug;
}
public function setLocaleSwitcher(LocaleSwitcher $localeSwitcher): void
{
$this->localeSwitcher = $localeSwitcher;
}
public function setEnabledLocales(array $enabledLocales): void
{
$this->enabledLocales = $enabledLocales;
}
/**
* Returns the current token.
*
* @throws \RuntimeException When the TokenStorage is not available
*/
public function getToken(): ?TokenInterface
{
if (!isset($this->tokenStorage)) {
throw new \RuntimeException('The "app.token" variable is not available.');
}
return $this->tokenStorage->getToken();
}
/**
* Returns the current user.
*
* @see TokenInterface::getUser()
*/
public function getUser(): ?UserInterface
{
if (!isset($this->tokenStorage)) {
throw new \RuntimeException('The "app.user" variable is not available.');
}
return $this->tokenStorage->getToken()?->getUser();
}
/**
* Returns the current request.
*/
public function getRequest(): ?Request
{
if (!isset($this->requestStack)) {
throw new \RuntimeException('The "app.request" variable is not available.');
}
return $this->requestStack->getCurrentRequest();
}
/**
* Returns the current session.
*/
public function getSession(): ?SessionInterface
{
if (!isset($this->requestStack)) {
throw new \RuntimeException('The "app.session" variable is not available.');
}
$request = $this->getRequest();
return $request?->hasSession() ? $request->getSession() : null;
}
/**
* Returns the current app environment.
*/
public function getEnvironment(): string
{
if (!isset($this->environment)) {
throw new \RuntimeException('The "app.environment" variable is not available.');
}
return $this->environment;
}
/**
* Returns the current app debug mode.
*/
public function getDebug(): bool
{
if (!isset($this->debug)) {
throw new \RuntimeException('The "app.debug" variable is not available.');
}
return $this->debug;
}
public function getLocale(): string
{
if (!isset($this->localeSwitcher)) {
throw new \RuntimeException('The "app.locale" variable is not available.');
}
return $this->localeSwitcher->getLocale();
}
public function getEnabled_locales(): array
{
if (!isset($this->enabledLocales)) {
throw new \RuntimeException('The "app.enabled_locales" variable is not available.');
}
return $this->enabledLocales;
}
/**
* Returns some or all the existing flash messages:
* * getFlashes() returns all the flash messages
* * getFlashes('notice') returns a simple array with flash messages of that type
* * getFlashes(['notice', 'error']) returns a nested array of type => messages.
*/
public function getFlashes(string|array|null $types = null): array
{
try {
if (null === $session = $this->getSession()) {
return [];
}
} catch (\RuntimeException) {
return [];
}
// In 7.0 (when symfony/http-foundation: 6.4 is required) this can be updated to
// check if the session is an instance of FlashBagAwareSessionInterface
if (!method_exists($session, 'getFlashBag')) {
return [];
}
if (null === $types || '' === $types || [] === $types) {
return $session->getFlashBag()->all();
}
if (\is_string($types)) {
return $session->getFlashBag()->get($types);
}
$result = [];
foreach ($types as $type) {
$result[$type] = $session->getFlashBag()->get($type);
}
return $result;
}
public function getCurrent_route(): ?string
{
if (!isset($this->requestStack)) {
throw new \RuntimeException('The "app.current_route" variable is not available.');
}
return $this->getRequest()?->attributes->get('_route');
}
/**
* @return array<string, mixed>
*/
public function getCurrent_route_parameters(): array
{
if (!isset($this->requestStack)) {
throw new \RuntimeException('The "app.current_route_parameters" variable is not available.');
}
return $this->getRequest()?->attributes->get('_route_params') ?? [];
}
}

View File

@@ -0,0 +1,34 @@
<?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\Bridge\Twig\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
class Template
{
public function __construct(
/**
* The name of the template to render.
*/
public string $template,
/**
* The controller method arguments to pass to the template.
*/
public ?array $vars = null,
/**
* Enables streaming the template.
*/
public bool $stream = false,
) {
}
}

View File

@@ -0,0 +1,195 @@
CHANGELOG
=========
6.4
---
* Allow an array to be passed as the first argument to the `importmap()` Twig function
* Add `TemplatedEmail::locale()` to set the locale for the email rendering
* Add `AppVariable::getEnabledLocales()` to retrieve the enabled locales
* Add `impersonation_path()` and `impersonation_url()` Twig functions
6.3
---
* Add `AppVariable::getLocale()` to retrieve the current locale when using the `LocaleSwitcher`
6.2
---
* Add `form_label_content` and `form_help_content` block to form themes
* Add `#[Template()]` to describe how to render arrays returned by controllers
* Add support for toggle buttons in Bootstrap 5 form theme
* Add `app.current_route` and `app.current_route_parameters` variables
6.1
---
* Wrap help messages on form elements in `div` instead of `p`
5.4
---
* Add `github` format & autodetection to render errors as annotations when
running the Twig linter command in a Github Actions environment.
5.3
---
* Add a new `markAsPublic` method on `NotificationEmail` to change the `importance` context option to null after creation
* Add a new `fragment_uri()` helper to generate the URI of a fragment
* Add support of Bootstrap 5 for form theming
* Add a new `serialize` filter to serialize objects using the Serializer component
5.2.0
-----
* added the `impersonation_exit_url()` and `impersonation_exit_path()` functions. They return a URL that allows to switch back to the original user.
* added the `workflow_transition()` function to easily retrieve a specific transition object
* added support for translating `TranslatableInterface` objects
* added the `t()` function to easily create `TranslatableMessage` objects
* Added support for extracting messages from the `t()` function
* Added `field_*` Twig functions to access string values from Form fields
* changed the `importance` context option of `NotificationEmail` to allow `null`
5.0.0
-----
* removed `TwigEngine` class, use `\Twig\Environment` instead.
* removed `transChoice` filter and token
* `HttpFoundationExtension` requires a `UrlHelper` on instantiation
* removed support for implicit STDIN usage in the `lint:twig` command, use `lint:twig -` (append a dash) instead to make it explicit.
* added form theme for Foundation 6
* added support for Foundation 6 switches: add the `switch-input` class to the attributes of a `CheckboxType`
4.4.0
-----
* added a new `TwigErrorRenderer` for `html` format, integrated with the `ErrorHandler` component
* marked all classes extending twig as `@final`
* deprecated to pass `$rootDir` and `$fileLinkFormatter` as 5th and 6th argument respectively to the
`DebugCommand::__construct()` method, swap the variables position.
* the `LintCommand` lints all the templates stored in all configured Twig paths if none argument is provided
* deprecated accepting STDIN implicitly when using the `lint:twig` command, use `lint:twig -` (append a dash) instead to make it explicit.
* added `--show-deprecations` option to the `lint:twig` command
* added support for Bootstrap4 switches: add the `switch-custom` class to the label attributes of a `CheckboxType`
* Marked the `TwigDataCollector` class as `@final`.
4.3.0
-----
* added the `form_parent()` function that allows to reliably retrieve the parent form in Twig templates
* added the `workflow_transition_blockers()` function
* deprecated the `$requestStack` and `$requestContext` arguments of the
`HttpFoundationExtension`, pass a `Symfony\Component\HttpFoundation\UrlHelper`
instance as the only argument instead
4.2.0
-----
* add bundle name suggestion on wrongly overridden templates paths
* added `name` argument in `debug:twig` command and changed `filter` argument as `--filter` option
* deprecated the `transchoice` tag and filter, use the `trans` ones instead with a `%count%` parameter
4.1.0
-----
* add a `workflow_metadata` function
3.4.0
-----
* added an `only` keyword to `form_theme` tag to disable usage of default themes when rendering a form
* deprecated `Symfony\Bridge\Twig\Form\TwigRenderer`
* deprecated `DebugCommand::set/getTwigEnvironment`. Pass an instance of
`Twig\Environment` as first argument of the constructor instead
* deprecated `LintCommand::set/getTwigEnvironment`. Pass an instance of
`Twig\Environment` as first argument of the constructor instead
3.3.0
-----
* added a `workflow_has_marked_place` function
* added a `workflow_marked_places` function
3.2.0
-----
* added `AppVariable::getToken()`
* Deprecated the possibility to inject the Form `TwigRenderer` into the `FormExtension`.
* [BC BREAK] Registering the `FormExtension` without configuring a runtime loader for the `TwigRenderer`
doesn't work anymore.
Before:
```php
use Symfony\Bridge\Twig\Extension\FormExtension;
use Symfony\Bridge\Twig\Form\TwigRenderer;
use Symfony\Bridge\Twig\Form\TwigRendererEngine;
// ...
$rendererEngine = new TwigRendererEngine(['form_div_layout.html.twig']);
$rendererEngine->setEnvironment($twig);
$twig->addExtension(new FormExtension(new TwigRenderer($rendererEngine, $csrfTokenManager)));
```
After:
```php
// ...
$rendererEngine = new TwigRendererEngine(['form_div_layout.html.twig'], $twig);
// require Twig 1.30+
$twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryRuntimeLoader([
TwigRenderer::class => function () use ($rendererEngine, $csrfTokenManager) {
return new TwigRenderer($rendererEngine, $csrfTokenManager);
},
]));
$twig->addExtension(new FormExtension());
```
* Deprecated the `TwigRendererEngineInterface` interface.
* added WorkflowExtension (provides `workflow_can` and `workflow_transitions`)
2.7.0
-----
* added LogoutUrlExtension (provides `logout_url` and `logout_path`)
* added an HttpFoundation extension (provides the `absolute_url` and the `relative_path` functions)
* added AssetExtension (provides the `asset` and `asset_version` functions)
* Added possibility to extract translation messages from a file or files besides extracting from a directory
2.5.0
-----
* moved command `twig:lint` from `TwigBundle`
2.4.0
-----
* added stopwatch tag to time templates with the WebProfilerBundle
2.3.0
-----
* added helpers form(), form_start() and form_end()
* deprecated form_enctype() in favor of form_start()
2.2.0
-----
* added a `controller` function to help generating controller references
* added a `render_esi` and a `render_hinclude` function
* [BC BREAK] restricted the `render` tag to only accept URIs or ControllerReference instances (the signature changed)
* added a `render` function to render a request
* The `app` global variable is now injected even when using the twig service directly.
* Added an optional parameter to the `path` and `url` function which allows to generate
relative paths (e.g. "../parent-file") and scheme-relative URLs (e.g. "//example.com/dir/file").
2.1.0
-----
* added global variables access in a form theme
* added TwigEngine
* added TwigExtractor
* added a csrf_token function
* added a way to specify a default domain for a Twig template (via the
'trans_default_domain' tag)

View File

@@ -0,0 +1,604 @@
<?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\Bridge\Twig\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\Exception\InvalidArgumentException;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Loader\ChainLoader;
use Twig\Loader\FilesystemLoader;
/**
* Lists twig functions, filters, globals and tests present in the current project.
*
* @author Jordi Boggiano <j.boggiano@seld.be>
*/
#[AsCommand(name: 'debug:twig', description: 'Show a list of twig functions, filters, globals and tests')]
class DebugCommand extends Command
{
private Environment $twig;
private ?string $projectDir;
private array $bundlesMetadata;
private ?string $twigDefaultPath;
/**
* @var FilesystemLoader[]
*/
private array $filesystemLoaders;
private ?FileLinkFormatter $fileLinkFormatter;
public function __construct(Environment $twig, ?string $projectDir = null, array $bundlesMetadata = [], ?string $twigDefaultPath = null, ?FileLinkFormatter $fileLinkFormatter = null)
{
parent::__construct();
$this->twig = $twig;
$this->projectDir = $projectDir;
$this->bundlesMetadata = $bundlesMetadata;
$this->twigDefaultPath = $twigDefaultPath;
$this->fileLinkFormatter = $fileLinkFormatter;
}
/**
* @return void
*/
protected function configure()
{
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'The template name'),
new InputOption('filter', null, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'text'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command outputs a list of twig functions,
filters, globals and tests.
<info>php %command.full_name%</info>
The command lists all functions, filters, etc.
<info>php %command.full_name% @Twig/Exception/error.html.twig</info>
The command lists all paths that match the given template name.
<info>php %command.full_name% --filter=date</info>
The command lists everything that contains the word date.
<info>php %command.full_name% --format=json</info>
The command lists everything in a machine readable json format.
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
$filter = $input->getOption('filter');
if (null !== $name && [] === $this->getFilesystemLoaders()) {
throw new InvalidArgumentException(sprintf('Argument "name" not supported, it requires the Twig loader "%s".', FilesystemLoader::class));
}
match ($input->getOption('format')) {
'text' => $name ? $this->displayPathsText($io, $name) : $this->displayGeneralText($io, $filter),
'json' => $name ? $this->displayPathsJson($io, $name) : $this->displayGeneralJson($io, $filter),
default => throw new InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))),
};
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues(array_keys($this->getLoaderPaths()));
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function displayPathsText(SymfonyStyle $io, string $name): void
{
$file = new \ArrayIterator($this->findTemplateFiles($name));
$paths = $this->getLoaderPaths($name);
$io->section('Matched File');
if ($file->valid()) {
if ($fileLink = $this->getFileLink($file->key())) {
$io->block($file->current(), 'OK', sprintf('fg=black;bg=green;href=%s', $fileLink), ' ', true);
} else {
$io->success($file->current());
}
$file->next();
if ($file->valid()) {
$io->section('Overridden Files');
do {
if ($fileLink = $this->getFileLink($file->key())) {
$io->text(sprintf('* <href=%s>%s</>', $fileLink, $file->current()));
} else {
$io->text(sprintf('* %s', $file->current()));
}
$file->next();
} while ($file->valid());
}
} else {
$alternatives = [];
if ($paths) {
$shortnames = [];
$dirs = [];
foreach (current($paths) as $path) {
$dirs[] = $this->isAbsolutePath($path) ? $path : $this->projectDir.'/'.$path;
}
foreach (Finder::create()->files()->followLinks()->in($dirs) as $file) {
$shortnames[] = str_replace('\\', '/', $file->getRelativePathname());
}
[$namespace, $shortname] = $this->parseTemplateName($name);
$alternatives = $this->findAlternatives($shortname, $shortnames);
if (FilesystemLoader::MAIN_NAMESPACE !== $namespace) {
$alternatives = array_map(fn ($shortname) => '@'.$namespace.'/'.$shortname, $alternatives);
}
}
$this->error($io, sprintf('Template name "%s" not found', $name), $alternatives);
}
$io->section('Configured Paths');
if ($paths) {
$io->table(['Namespace', 'Paths'], $this->buildTableRows($paths));
} else {
$alternatives = [];
$namespace = $this->parseTemplateName($name)[0];
if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
$message = 'No template paths configured for your application';
} else {
$message = sprintf('No template paths configured for "@%s" namespace', $namespace);
foreach ($this->getFilesystemLoaders() as $loader) {
$namespaces = $loader->getNamespaces();
foreach ($this->findAlternatives($namespace, $namespaces) as $namespace) {
$alternatives[] = '@'.$namespace;
}
}
}
$this->error($io, $message, $alternatives);
if (!$alternatives && $paths = $this->getLoaderPaths()) {
$io->table(['Namespace', 'Paths'], $this->buildTableRows($paths));
}
}
}
private function displayPathsJson(SymfonyStyle $io, string $name): void
{
$files = $this->findTemplateFiles($name);
$paths = $this->getLoaderPaths($name);
if ($files) {
$data['matched_file'] = array_shift($files);
if ($files) {
$data['overridden_files'] = $files;
}
} else {
$data['matched_file'] = sprintf('Template name "%s" not found', $name);
}
$data['loader_paths'] = $paths;
$io->writeln(json_encode($data));
}
private function displayGeneralText(SymfonyStyle $io, ?string $filter = null): void
{
$decorated = $io->isDecorated();
$types = ['functions', 'filters', 'tests', 'globals'];
foreach ($types as $index => $type) {
$items = [];
foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
if (!$filter || str_contains($name, $filter)) {
$items[$name] = $name.$this->getPrettyMetadata($type, $entity, $decorated);
}
}
if (!$items) {
continue;
}
$io->section(ucfirst($type));
ksort($items);
$io->listing($items);
}
if (!$filter && $paths = $this->getLoaderPaths()) {
$io->section('Loader Paths');
$io->table(['Namespace', 'Paths'], $this->buildTableRows($paths));
}
if ($wrongBundles = $this->findWrongBundleOverrides()) {
foreach ($this->buildWarningMessages($wrongBundles) as $message) {
$io->warning($message);
}
}
}
private function displayGeneralJson(SymfonyStyle $io, ?string $filter): void
{
$decorated = $io->isDecorated();
$types = ['functions', 'filters', 'tests', 'globals'];
$data = [];
foreach ($types as $type) {
foreach ($this->twig->{'get'.ucfirst($type)}() as $name => $entity) {
if (!$filter || str_contains($name, $filter)) {
$data[$type][$name] = $this->getMetadata($type, $entity);
}
}
}
if (isset($data['tests'])) {
$data['tests'] = array_keys($data['tests']);
}
if (!$filter && $paths = $this->getLoaderPaths($filter)) {
$data['loader_paths'] = $paths;
}
if ($wrongBundles = $this->findWrongBundleOverrides()) {
$data['warnings'] = $this->buildWarningMessages($wrongBundles);
}
$data = json_encode($data, \JSON_PRETTY_PRINT);
$io->writeln($decorated ? OutputFormatter::escape($data) : $data);
}
private function getLoaderPaths(?string $name = null): array
{
$loaderPaths = [];
foreach ($this->getFilesystemLoaders() as $loader) {
$namespaces = $loader->getNamespaces();
if (null !== $name) {
$namespace = $this->parseTemplateName($name)[0];
$namespaces = array_intersect([$namespace], $namespaces);
}
foreach ($namespaces as $namespace) {
$paths = array_map($this->getRelativePath(...), $loader->getPaths($namespace));
if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
$namespace = '(None)';
} else {
$namespace = '@'.$namespace;
}
$loaderPaths[$namespace] = array_merge($loaderPaths[$namespace] ?? [], $paths);
}
}
return $loaderPaths;
}
private function getMetadata(string $type, mixed $entity): mixed
{
if ('globals' === $type) {
return $entity;
}
if ('tests' === $type) {
return null;
}
if ('functions' === $type || 'filters' === $type) {
$cb = $entity->getCallable();
if (null === $cb) {
return null;
}
if (\is_array($cb)) {
if (!method_exists($cb[0], $cb[1])) {
return null;
}
$refl = new \ReflectionMethod($cb[0], $cb[1]);
} elseif (\is_object($cb) && method_exists($cb, '__invoke')) {
$refl = new \ReflectionMethod($cb, '__invoke');
} elseif (\function_exists($cb)) {
$refl = new \ReflectionFunction($cb);
} elseif (\is_string($cb) && preg_match('{^(.+)::(.+)$}', $cb, $m) && method_exists($m[1], $m[2])) {
$refl = new \ReflectionMethod($m[1], $m[2]);
} else {
throw new \UnexpectedValueException('Unsupported callback type.');
}
$args = $refl->getParameters();
// filter out context/environment args
if ($entity->needsEnvironment()) {
array_shift($args);
}
if ($entity->needsContext()) {
array_shift($args);
}
if ('filters' === $type) {
// remove the value the filter is applied on
array_shift($args);
}
// format args
$args = array_map(function (\ReflectionParameter $param) {
if ($param->isDefaultValueAvailable()) {
return $param->getName().' = '.json_encode($param->getDefaultValue());
}
return $param->getName();
}, $args);
return $args;
}
return null;
}
private function getPrettyMetadata(string $type, mixed $entity, bool $decorated): ?string
{
if ('tests' === $type) {
return '';
}
try {
$meta = $this->getMetadata($type, $entity);
if (null === $meta) {
return '(unknown?)';
}
} catch (\UnexpectedValueException $e) {
return sprintf(' <error>%s</error>', $decorated ? OutputFormatter::escape($e->getMessage()) : $e->getMessage());
}
if ('globals' === $type) {
if (\is_object($meta)) {
return ' = object('.$meta::class.')';
}
$description = substr(@json_encode($meta), 0, 50);
return sprintf(' = %s', $decorated ? OutputFormatter::escape($description) : $description);
}
if ('functions' === $type) {
return '('.implode(', ', $meta).')';
}
if ('filters' === $type) {
return $meta ? '('.implode(', ', $meta).')' : '';
}
return null;
}
private function findWrongBundleOverrides(): array
{
$alternatives = [];
$bundleNames = [];
if ($this->twigDefaultPath && $this->projectDir) {
$folders = glob($this->twigDefaultPath.'/bundles/*', \GLOB_ONLYDIR);
$relativePath = ltrim(substr($this->twigDefaultPath.'/bundles/', \strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
$bundleNames = array_reduce($folders, function ($carry, $absolutePath) use ($relativePath) {
if (str_starts_with($absolutePath, $this->projectDir)) {
$name = basename($absolutePath);
$path = ltrim($relativePath.$name, \DIRECTORY_SEPARATOR);
$carry[$name] = $path;
}
return $carry;
}, $bundleNames);
}
if ($notFoundBundles = array_diff_key($bundleNames, $this->bundlesMetadata)) {
$alternatives = [];
foreach ($notFoundBundles as $notFoundBundle => $path) {
$alternatives[$path] = $this->findAlternatives($notFoundBundle, array_keys($this->bundlesMetadata));
}
}
return $alternatives;
}
private function buildWarningMessages(array $wrongBundles): array
{
$messages = [];
foreach ($wrongBundles as $path => $alternatives) {
$message = sprintf('Path "%s" not matching any bundle found', $path);
if ($alternatives) {
if (1 === \count($alternatives)) {
$message .= sprintf(", did you mean \"%s\"?\n", $alternatives[0]);
} else {
$message .= ", did you mean one of these:\n";
foreach ($alternatives as $bundle) {
$message .= sprintf(" - %s\n", $bundle);
}
}
}
$messages[] = trim($message);
}
return $messages;
}
private function error(SymfonyStyle $io, string $message, array $alternatives = []): void
{
if ($alternatives) {
if (1 === \count($alternatives)) {
$message .= "\n\nDid you mean this?\n ";
} else {
$message .= "\n\nDid you mean one of these?\n ";
}
$message .= implode("\n ", $alternatives);
}
$io->block($message, null, 'fg=white;bg=red', ' ', true);
}
private function findTemplateFiles(string $name): array
{
[$namespace, $shortname] = $this->parseTemplateName($name);
$files = [];
foreach ($this->getFilesystemLoaders() as $loader) {
foreach ($loader->getPaths($namespace) as $path) {
if (!$this->isAbsolutePath($path)) {
$path = $this->projectDir.'/'.$path;
}
$filename = $path.'/'.$shortname;
if (is_file($filename)) {
if (false !== $realpath = realpath($filename)) {
$files[$realpath] = $this->getRelativePath($realpath);
} else {
$files[$filename] = $this->getRelativePath($filename);
}
}
}
}
return $files;
}
private function parseTemplateName(string $name, string $default = FilesystemLoader::MAIN_NAMESPACE): array
{
if (isset($name[0]) && '@' === $name[0]) {
if (false === ($pos = strpos($name, '/')) || $pos === \strlen($name) - 1) {
throw new InvalidArgumentException(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name));
}
$namespace = substr($name, 1, $pos - 1);
$shortname = substr($name, $pos + 1);
return [$namespace, $shortname];
}
return [$default, $name];
}
private function buildTableRows(array $loaderPaths): array
{
$rows = [];
$firstNamespace = true;
$prevHasSeparator = false;
foreach ($loaderPaths as $namespace => $paths) {
if (!$firstNamespace && !$prevHasSeparator && \count($paths) > 1) {
$rows[] = ['', ''];
}
$firstNamespace = false;
foreach ($paths as $path) {
$rows[] = [$namespace, $path.\DIRECTORY_SEPARATOR];
$namespace = '';
}
if (\count($paths) > 1) {
$rows[] = ['', ''];
$prevHasSeparator = true;
} else {
$prevHasSeparator = false;
}
}
if ($prevHasSeparator) {
array_pop($rows);
}
return $rows;
}
private function findAlternatives(string $name, array $collection): array
{
$alternatives = [];
foreach ($collection as $item) {
$lev = levenshtein($name, $item);
if ($lev <= \strlen($name) / 3 || str_contains($item, $name)) {
$alternatives[$item] = isset($alternatives[$item]) ? $alternatives[$item] - $lev : $lev;
}
}
$threshold = 1e3;
$alternatives = array_filter($alternatives, fn ($lev) => $lev < 2 * $threshold);
ksort($alternatives, \SORT_NATURAL | \SORT_FLAG_CASE);
return array_keys($alternatives);
}
private function getRelativePath(string $path): string
{
if (null !== $this->projectDir && str_starts_with($path, $this->projectDir)) {
return ltrim(substr($path, \strlen($this->projectDir)), \DIRECTORY_SEPARATOR);
}
return $path;
}
private function isAbsolutePath(string $file): bool
{
return strspn($file, '/\\', 0, 1) || (\strlen($file) > 3 && ctype_alpha($file[0]) && ':' === $file[1] && strspn($file, '/\\', 2, 1)) || parse_url($file, \PHP_URL_SCHEME);
}
/**
* @return FilesystemLoader[]
*/
private function getFilesystemLoaders(): array
{
if (isset($this->filesystemLoaders)) {
return $this->filesystemLoaders;
}
$this->filesystemLoaders = [];
$loader = $this->twig->getLoader();
if ($loader instanceof FilesystemLoader) {
$this->filesystemLoaders[] = $loader;
} elseif ($loader instanceof ChainLoader) {
foreach ($loader->getLoaders() as $l) {
if ($l instanceof FilesystemLoader) {
$this->filesystemLoaders[] = $l;
}
}
}
return $this->filesystemLoaders;
}
private function getFileLink(string $absolutePath): string
{
if (null === $this->fileLinkFormatter) {
return '';
}
return (string) $this->fileLinkFormatter->format($absolutePath, 1);
}
private function getAvailableFormatOptions(): array
{
return ['text', 'json'];
}
}

View File

@@ -0,0 +1,287 @@
<?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\Bridge\Twig\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\CI\GithubActionReporter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Error\Error;
use Twig\Loader\ArrayLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Source;
/**
* Command that will validate your template syntax and output encountered errors.
*
* @author Marc Weistroff <marc.weistroff@sensiolabs.com>
* @author Jérôme Tamarelle <jerome@tamarelle.net>
*/
#[AsCommand(name: 'lint:twig', description: 'Lint a Twig template and outputs encountered errors')]
class LintCommand extends Command
{
private string $format;
public function __construct(
private Environment $twig,
private array $namePatterns = ['*.twig'],
) {
parent::__construct();
}
/**
* @return void
*/
protected function configure()
{
$this
->addOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())))
->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors')
->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command lints a template and outputs to STDOUT
the first encountered syntax error.
You can validate the syntax of contents passed from STDIN:
<info>cat filename | php %command.full_name% -</info>
Or the syntax of a file:
<info>php %command.full_name% filename</info>
Or of a whole directory:
<info>php %command.full_name% dirname</info>
<info>php %command.full_name% dirname --format=json</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$filenames = $input->getArgument('filename');
$showDeprecations = $input->getOption('show-deprecations');
$this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt');
if (['-'] === $filenames) {
return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), uniqid('sf_', true))]);
}
if (!$filenames) {
$loader = $this->twig->getLoader();
if ($loader instanceof FilesystemLoader) {
$paths = [];
foreach ($loader->getNamespaces() as $namespace) {
$paths[] = $loader->getPaths($namespace);
}
$filenames = array_merge(...$paths);
}
if (!$filenames) {
throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
}
}
if ($showDeprecations) {
$prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) {
if (\E_USER_DEPRECATED === $level) {
$templateLine = 0;
if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
$templateLine = $matches[1];
}
throw new Error($message, $templateLine);
}
return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false;
});
}
try {
$filesInfo = $this->getFilesInfo($filenames);
} finally {
if ($showDeprecations) {
restore_error_handler();
}
}
return $this->display($input, $output, $io, $filesInfo);
}
private function getFilesInfo(array $filenames): array
{
$filesInfo = [];
foreach ($filenames as $filename) {
foreach ($this->findFiles($filename) as $file) {
$filesInfo[] = $this->validate(file_get_contents($file), $file);
}
}
return $filesInfo;
}
protected function findFiles(string $filename): iterable
{
if (is_file($filename)) {
return [$filename];
} elseif (is_dir($filename)) {
return Finder::create()->files()->in($filename)->name($this->namePatterns);
}
throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename));
}
private function validate(string $template, string $file): array
{
$realLoader = $this->twig->getLoader();
try {
$temporaryLoader = new ArrayLoader([$file => $template]);
$this->twig->setLoader($temporaryLoader);
$nodeTree = $this->twig->parse($this->twig->tokenize(new Source($template, $file)));
$this->twig->compile($nodeTree);
$this->twig->setLoader($realLoader);
} catch (Error $e) {
$this->twig->setLoader($realLoader);
return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e];
}
return ['template' => $template, 'file' => $file, 'valid' => true];
}
private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files): int
{
return match ($this->format) {
'txt' => $this->displayTxt($output, $io, $files),
'json' => $this->displayJson($output, $files),
'github' => $this->displayTxt($output, $io, $files, true),
default => throw new InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))),
};
}
private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int
{
$errors = 0;
$githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($output) : null;
foreach ($filesInfo as $info) {
if ($info['valid'] && $output->isVerbose()) {
$io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : ''));
} elseif (!$info['valid']) {
++$errors;
$this->renderException($io, $info['template'], $info['exception'], $info['file'], $githubReporter);
}
}
if (0 === $errors) {
$io->success(sprintf('All %d Twig files contain valid syntax.', \count($filesInfo)));
} else {
$io->warning(sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors));
}
return min($errors, 1);
}
private function displayJson(OutputInterface $output, array $filesInfo): int
{
$errors = 0;
array_walk($filesInfo, function (&$v) use (&$errors) {
$v['file'] = (string) $v['file'];
unset($v['template']);
if (!$v['valid']) {
$v['message'] = $v['exception']->getMessage();
unset($v['exception']);
++$errors;
}
});
$output->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
return min($errors, 1);
}
private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null): void
{
$line = $exception->getTemplateLine();
$githubReporter?->error($exception->getRawMessage(), $file, $line <= 0 ? null : $line);
if ($file) {
$output->text(sprintf('<error> ERROR </error> in %s (line %s)', $file, $line));
} else {
$output->text(sprintf('<error> ERROR </error> (line %s)', $line));
}
// If the line is not known (this might happen for deprecations if we fail at detecting the line for instance),
// we render the message without context, to ensure the message is displayed.
if ($line <= 0) {
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage()));
return;
}
foreach ($this->getContext($template, $line) as $lineNumber => $code) {
$output->text(sprintf(
'%s %-6s %s',
$lineNumber === $line ? '<error> >> </error>' : ' ',
$lineNumber,
$code
));
if ($lineNumber === $line) {
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage()));
}
}
}
private function getContext(string $template, int $line, int $context = 3): array
{
$lines = explode("\n", $template);
$position = max(0, $line - $context);
$max = min(\count($lines), $line - 1 + $context);
$result = [];
while ($position < $max) {
$result[$position + 1] = $lines[$position];
++$position;
}
return $result;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function getAvailableFormatOptions(): array
{
return ['txt', 'json', 'github'];
}
}

View File

@@ -0,0 +1,185 @@
<?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\Bridge\Twig\DataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Markup;
use Twig\Profiler\Dumper\HtmlDumper;
use Twig\Profiler\Profile;
/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @final
*/
class TwigDataCollector extends DataCollector implements LateDataCollectorInterface
{
private Profile $profile;
private ?Environment $twig;
private array $computed;
public function __construct(Profile $profile, ?Environment $twig = null)
{
$this->profile = $profile;
$this->twig = $twig;
}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
}
public function reset(): void
{
$this->profile->reset();
unset($this->computed);
$this->data = [];
}
public function lateCollect(): void
{
$this->data['profile'] = serialize($this->profile);
$this->data['template_paths'] = [];
if (null === $this->twig) {
return;
}
$templateFinder = function (Profile $profile) use (&$templateFinder) {
if ($profile->isTemplate()) {
try {
$template = $this->twig->load($name = $profile->getName());
} catch (LoaderError) {
$template = null;
}
if (null !== $template && '' !== $path = $template->getSourceContext()->getPath()) {
$this->data['template_paths'][$name] = $path;
}
}
foreach ($profile as $p) {
$templateFinder($p);
}
};
$templateFinder($this->profile);
}
public function getTime(): float
{
return $this->getProfile()->getDuration() * 1000;
}
public function getTemplateCount(): int
{
return $this->getComputedData('template_count');
}
public function getTemplatePaths(): array
{
return $this->data['template_paths'];
}
public function getTemplates(): array
{
return $this->getComputedData('templates');
}
public function getBlockCount(): int
{
return $this->getComputedData('block_count');
}
public function getMacroCount(): int
{
return $this->getComputedData('macro_count');
}
public function getHtmlCallGraph(): Markup
{
$dumper = new HtmlDumper();
$dump = $dumper->dump($this->getProfile());
// needed to remove the hardcoded CSS styles
$dump = str_replace([
'<span style="background-color: #ffd">',
'<span style="color: #d44">',
'<span style="background-color: #dfd">',
'<span style="background-color: #ddf">',
], [
'<span class="status-warning">',
'<span class="status-error">',
'<span class="status-success">',
'<span class="status-info">',
], $dump);
return new Markup($dump, 'UTF-8');
}
public function getProfile(): Profile
{
return $this->profile ??= unserialize($this->data['profile'], ['allowed_classes' => ['Twig_Profiler_Profile', Profile::class]]);
}
private function getComputedData(string $index): mixed
{
$this->computed ??= $this->computeData($this->getProfile());
return $this->computed[$index];
}
private function computeData(Profile $profile): array
{
$data = [
'template_count' => 0,
'block_count' => 0,
'macro_count' => 0,
];
$templates = [];
foreach ($profile as $p) {
$d = $this->computeData($p);
$data['template_count'] += ($p->isTemplate() ? 1 : 0) + $d['template_count'];
$data['block_count'] += ($p->isBlock() ? 1 : 0) + $d['block_count'];
$data['macro_count'] += ($p->isMacro() ? 1 : 0) + $d['macro_count'];
if ($p->isTemplate()) {
if (!isset($templates[$p->getTemplate()])) {
$templates[$p->getTemplate()] = 1;
} else {
++$templates[$p->getTemplate()];
}
}
foreach ($d['templates'] as $template => $count) {
if (!isset($templates[$template])) {
$templates[$template] = $count;
} else {
$templates[$template] += $count;
}
}
}
$data['templates'] = $templates;
return $data;
}
public function getName(): string
{
return 'twig';
}
}

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\Bridge\Twig\ErrorRenderer;
use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface;
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
/**
* Provides the ability to render custom Twig-based HTML error pages
* in non-debug mode, otherwise falls back to HtmlErrorRenderer.
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class TwigErrorRenderer implements ErrorRendererInterface
{
private Environment $twig;
private HtmlErrorRenderer $fallbackErrorRenderer;
private \Closure|bool $debug;
/**
* @param bool|callable $debug The debugging mode as a boolean or a callable that should return it
*/
public function __construct(Environment $twig, ?HtmlErrorRenderer $fallbackErrorRenderer = null, bool|callable $debug = false)
{
$this->twig = $twig;
$this->fallbackErrorRenderer = $fallbackErrorRenderer ?? new HtmlErrorRenderer();
$this->debug = \is_bool($debug) ? $debug : $debug(...);
}
public function render(\Throwable $exception): FlattenException
{
$flattenException = FlattenException::createFromThrowable($exception);
$debug = \is_bool($this->debug) ? $this->debug : ($this->debug)($flattenException);
if ($debug || !$template = $this->findTemplate($flattenException->getStatusCode())) {
return $this->fallbackErrorRenderer->render($exception);
}
return $flattenException->setAsString($this->twig->render($template, [
'exception' => $flattenException,
'status_code' => $flattenException->getStatusCode(),
'status_text' => $flattenException->getStatusText(),
]));
}
public static function isDebug(RequestStack $requestStack, bool $debug): \Closure
{
return static function () use ($requestStack, $debug): bool {
if (!$request = $requestStack->getCurrentRequest()) {
return $debug;
}
return $debug && $request->attributes->getBoolean('showException', true);
};
}
private function findTemplate(int $statusCode): ?string
{
$template = sprintf('@Twig/Exception/error%s.html.twig', $statusCode);
if ($this->twig->getLoader()->exists($template)) {
return $template;
}
$template = '@Twig/Exception/error.html.twig';
if ($this->twig->getLoader()->exists($template)) {
return $template;
}
return null;
}
}

View File

@@ -0,0 +1,87 @@
<?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\Bridge\Twig\EventListener;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
class TemplateAttributeListener implements EventSubscriberInterface
{
public function __construct(
private Environment $twig,
) {
}
/**
* @return void
*/
public function onKernelView(ViewEvent $event)
{
$parameters = $event->getControllerResult();
if (!\is_array($parameters ?? [])) {
return;
}
$attribute = $event->getRequest()->attributes->get('_template');
if (!$attribute instanceof Template && !$attribute = $event->controllerArgumentsEvent?->getAttributes()[Template::class][0] ?? null) {
return;
}
$parameters ??= $this->resolveParameters($event->controllerArgumentsEvent, $attribute->vars);
$status = 200;
foreach ($parameters as $k => $v) {
if (!$v instanceof FormInterface) {
continue;
}
if ($v->isSubmitted() && !$v->isValid()) {
$status = 422;
}
$parameters[$k] = $v->createView();
}
$event->setResponse($attribute->stream
? new StreamedResponse(fn () => $this->twig->display($attribute->template, $parameters), $status)
: new Response($this->twig->render($attribute->template, $parameters), $status)
);
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::VIEW => ['onKernelView', -128],
];
}
private function resolveParameters(ControllerArgumentsEvent $event, ?array $vars): array
{
if ([] === $vars) {
return [];
}
$parameters = $event->getNamedArguments();
if (null !== $vars) {
$parameters = array_intersect_key($parameters, array_flip($vars));
}
return $parameters;
}
}

View File

@@ -0,0 +1,58 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Asset\Packages;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Twig extension for the Symfony Asset component.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class AssetExtension extends AbstractExtension
{
private Packages $packages;
public function __construct(Packages $packages)
{
$this->packages = $packages;
}
public function getFunctions(): array
{
return [
new TwigFunction('asset', $this->getAssetUrl(...)),
new TwigFunction('asset_version', $this->getAssetVersion(...)),
];
}
/**
* Returns the public url/path of an asset.
*
* If the package used to generate the path is an instance of
* UrlPackage, you will always get a URL and not a path.
*/
public function getAssetUrl(string $path, ?string $packageName = null): string
{
return $this->packages->getUrl($path, $packageName);
}
/**
* Returns the version of an asset.
*/
public function getAssetVersion(string $path, ?string $packageName = null): string
{
return $this->packages->getVersion($path, $packageName);
}
}

View File

@@ -0,0 +1,252 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* Twig extension relate to PHP code and used by the profiler and the default exception templates.
*
* This extension should only be used for debugging tools code
* that is never executed in a production environment.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal since Symfony 6.4
*/
final class CodeExtension extends AbstractExtension
{
private string|FileLinkFormatter|array|false $fileLinkFormat;
private string $charset;
private string $projectDir;
public function __construct(string|FileLinkFormatter $fileLinkFormat, string $projectDir, string $charset)
{
$this->fileLinkFormat = $fileLinkFormat ?: \ini_get('xdebug.file_link_format') ?: get_cfg_var('xdebug.file_link_format');
$this->projectDir = str_replace('\\', '/', $projectDir).'/';
$this->charset = $charset;
}
public function getFilters(): array
{
return [
new TwigFilter('abbr_class', $this->abbrClass(...), ['is_safe' => ['html'], 'pre_escape' => 'html']),
new TwigFilter('abbr_method', $this->abbrMethod(...), ['is_safe' => ['html'], 'pre_escape' => 'html']),
new TwigFilter('format_args', $this->formatArgs(...), ['is_safe' => ['html']]),
new TwigFilter('format_args_as_text', $this->formatArgsAsText(...)),
new TwigFilter('file_excerpt', $this->fileExcerpt(...), ['is_safe' => ['html']]),
new TwigFilter('format_file', $this->formatFile(...), ['is_safe' => ['html']]),
new TwigFilter('format_file_from_text', $this->formatFileFromText(...), ['is_safe' => ['html']]),
new TwigFilter('format_log_message', $this->formatLogMessage(...), ['is_safe' => ['html']]),
new TwigFilter('file_link', $this->getFileLink(...)),
new TwigFilter('file_relative', $this->getFileRelative(...)),
];
}
public function abbrClass(string $class): string
{
$parts = explode('\\', $class);
$short = array_pop($parts);
return sprintf('<abbr title="%s">%s</abbr>', $class, $short);
}
public function abbrMethod(string $method): string
{
if (str_contains($method, '::')) {
[$class, $method] = explode('::', $method, 2);
$result = sprintf('%s::%s()', $this->abbrClass($class), $method);
} elseif ('Closure' === $method) {
$result = sprintf('<abbr title="%s">%1$s</abbr>', $method);
} else {
$result = sprintf('<abbr title="%s">%1$s</abbr>()', $method);
}
return $result;
}
/**
* Formats an array as a string.
*/
public function formatArgs(array $args): string
{
$result = [];
foreach ($args as $key => $item) {
if ('object' === $item[0]) {
$item[1] = htmlspecialchars($item[1], \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset);
$parts = explode('\\', $item[1]);
$short = array_pop($parts);
$formattedValue = sprintf('<em>object</em>(<abbr title="%s">%s</abbr>)', $item[1], $short);
} elseif ('array' === $item[0]) {
$formattedValue = sprintf('<em>array</em>(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset));
} elseif ('null' === $item[0]) {
$formattedValue = '<em>null</em>';
} elseif ('boolean' === $item[0]) {
$formattedValue = '<em>'.strtolower(htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)).'</em>';
} elseif ('resource' === $item[0]) {
$formattedValue = '<em>resource</em>';
} elseif (preg_match('/[^\x07-\x0D\x1B\x20-\xFF]/', $item[1])) {
$formattedValue = '<em>binary string</em>';
} else {
$formattedValue = str_replace("\n", '', htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset));
}
$result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", htmlspecialchars($key, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $formattedValue);
}
return implode(', ', $result);
}
/**
* Formats an array as a string.
*/
public function formatArgsAsText(array $args): string
{
return strip_tags($this->formatArgs($args));
}
/**
* Returns an excerpt of a code file around the given line number.
*/
public function fileExcerpt(string $file, int $line, int $srcContext = 3): ?string
{
if (is_file($file) && is_readable($file)) {
// highlight_file could throw warnings
// see https://bugs.php.net/25725
$code = @highlight_file($file, true);
if (\PHP_VERSION_ID >= 80300) {
// remove main pre/code tags
$code = preg_replace('#^<pre.*?>\s*<code.*?>(.*)</code>\s*</pre>#s', '\\1', $code);
// split multiline span tags
$code = preg_replace_callback('#<span ([^>]++)>((?:[^<\\n]*+\\n)++[^<]*+)</span>#', function ($m) {
return "<span $m[1]>".str_replace("\n", "</span>\n<span $m[1]>", $m[2]).'</span>';
}, $code);
$content = explode("\n", $code);
} else {
// remove main code/span tags
$code = preg_replace('#^<code.*?>\s*<span.*?>(.*)</span>\s*</code>#s', '\\1', $code);
// split multiline spans
$code = preg_replace_callback('#<span ([^>]++)>((?:[^<]*+<br \/>)++[^<]*+)</span>#', fn ($m) => "<span $m[1]>".str_replace('<br />', "</span><br /><span $m[1]>", $m[2]).'</span>', $code);
$content = explode('<br />', $code);
}
$lines = [];
if (0 > $srcContext) {
$srcContext = \count($content);
}
for ($i = max($line - $srcContext, 1), $max = min($line + $srcContext, \count($content)); $i <= $max; ++$i) {
$lines[] = '<li'.($i == $line ? ' class="selected"' : '').'><a class="anchor" id="line'.$i.'"></a><code>'.self::fixCodeMarkup($content[$i - 1]).'</code></li>';
}
return '<ol start="'.max($line - $srcContext, 1).'">'.implode("\n", $lines).'</ol>';
}
return null;
}
/**
* Formats a file path.
*/
public function formatFile(string $file, int $line, ?string $text = null): string
{
$file = trim($file);
if (null === $text) {
if (null !== $rel = $this->getFileRelative($file)) {
$rel = explode('/', htmlspecialchars($rel, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), 2);
$text = sprintf('<abbr title="%s%2$s">%s</abbr>%s', htmlspecialchars($this->projectDir, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $rel[0], '/'.($rel[1] ?? ''));
} else {
$text = htmlspecialchars($file, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset);
}
} else {
$text = htmlspecialchars($text, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset);
}
if (0 < $line) {
$text .= ' at line '.$line;
}
if (false !== $link = $this->getFileLink($file, $line)) {
return sprintf('<a href="%s" title="Click to open this file" class="file_link">%s</a>', htmlspecialchars($link, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $text);
}
return $text;
}
public function getFileLink(string $file, int $line): string|false
{
if ($fmt = $this->fileLinkFormat) {
return \is_string($fmt) ? strtr($fmt, ['%f' => $file, '%l' => $line]) : $fmt->format($file, $line);
}
return false;
}
public function getFileRelative(string $file): ?string
{
$file = str_replace('\\', '/', $file);
if (null !== $this->projectDir && str_starts_with($file, $this->projectDir)) {
return ltrim(substr($file, \strlen($this->projectDir)), '/');
}
return null;
}
public function formatFileFromText(string $text): string
{
return preg_replace_callback('/in ("|&quot;)?(.+?)\1(?: +(?:on|at))? +line (\d+)/s', fn ($match) => 'in '.$this->formatFile($match[2], $match[3]), $text);
}
/**
* @internal
*/
public function formatLogMessage(string $message, array $context): string
{
if ($context && str_contains($message, '{')) {
$replacements = [];
foreach ($context as $key => $val) {
if (\is_scalar($val)) {
$replacements['{'.$key.'}'] = $val;
}
}
if ($replacements) {
$message = strtr($message, $replacements);
}
}
return htmlspecialchars($message, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset);
}
protected static function fixCodeMarkup(string $line): string
{
// </span> ending tag from previous line
$opening = strpos($line, '<span');
$closing = strpos($line, '</span>');
if (false !== $closing && (false === $opening || $closing < $opening)) {
$line = substr_replace($line, '', $closing, 7);
}
// missing </span> tag at the end of line
$opening = strpos($line, '<span');
$closing = strpos($line, '</span>');
if (false !== $opening && (false === $closing || $closing > $opening)) {
$line .= '</span>';
}
return trim($line);
}
}

View File

@@ -0,0 +1,29 @@
<?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\Bridge\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* @author Christian Flothmann <christian.flothmann@sensiolabs.de>
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class CsrfExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('csrf_token', [CsrfRuntime::class, 'getCsrfToken']),
];
}
}

View File

@@ -0,0 +1,33 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* @author Christian Flothmann <christian.flothmann@sensiolabs.de>
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class CsrfRuntime
{
private CsrfTokenManagerInterface $csrfTokenManager;
public function __construct(CsrfTokenManagerInterface $csrfTokenManager)
{
$this->csrfTokenManager = $csrfTokenManager;
}
public function getCsrfToken(string $tokenId): string
{
return $this->csrfTokenManager->getToken($tokenId)->getValue();
}
}

View File

@@ -0,0 +1,80 @@
<?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\Bridge\Twig\Extension;
use Symfony\Bridge\Twig\TokenParser\DumpTokenParser;
use Symfony\Component\VarDumper\Cloner\ClonerInterface;
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Template;
use Twig\TwigFunction;
/**
* Provides integration of the dump() function with Twig.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class DumpExtension extends AbstractExtension
{
private ClonerInterface $cloner;
private ?HtmlDumper $dumper;
public function __construct(ClonerInterface $cloner, ?HtmlDumper $dumper = null)
{
$this->cloner = $cloner;
$this->dumper = $dumper;
}
public function getFunctions(): array
{
return [
new TwigFunction('dump', $this->dump(...), ['is_safe' => ['html'], 'needs_context' => true, 'needs_environment' => true]),
];
}
public function getTokenParsers(): array
{
return [new DumpTokenParser()];
}
public function dump(Environment $env, array $context): ?string
{
if (!$env->isDebug()) {
return null;
}
if (2 === \func_num_args()) {
$vars = [];
foreach ($context as $key => $value) {
if (!$value instanceof Template) {
$vars[$key] = $value;
}
}
$vars = [$vars];
} else {
$vars = \func_get_args();
unset($vars[0], $vars[1]);
}
$dump = fopen('php://memory', 'r+');
$this->dumper ??= new HtmlDumper();
$this->dumper->setCharset($env->getCharset());
foreach ($vars as $value) {
$this->dumper->dump($this->cloner->cloneVar($value), $dump);
}
return stream_get_contents($dump, -1, 0);
}
}

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\Bridge\Twig\Extension;
use Symfony\Component\ExpressionLanguage\Expression;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* ExpressionExtension gives a way to create Expressions from a template.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class ExpressionExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('expression', $this->createExpression(...)),
];
}
public function createExpression(string $expression): Expression
{
return new Expression($expression);
}
}

View File

@@ -0,0 +1,209 @@
<?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\Bridge\Twig\Extension;
use Symfony\Bridge\Twig\Node\RenderBlockNode;
use Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode;
use Symfony\Bridge\Twig\TokenParser\FormThemeTokenParser;
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormRenderer;
use Symfony\Component\Form\FormView;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* FormExtension extends Twig with form capabilities.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Bernhard Schussek <bschussek@gmail.com>
*/
final class FormExtension extends AbstractExtension
{
private ?TranslatorInterface $translator;
public function __construct(?TranslatorInterface $translator = null)
{
$this->translator = $translator;
}
public function getTokenParsers(): array
{
return [
// {% form_theme form "SomeBundle::widgets.twig" %}
new FormThemeTokenParser(),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('form_widget', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_errors', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_label', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_help', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_row', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_rest', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_start', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('form_end', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]),
new TwigFunction('csrf_token', [FormRenderer::class, 'renderCsrfToken']),
new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'),
new TwigFunction('field_name', $this->getFieldName(...)),
new TwigFunction('field_value', $this->getFieldValue(...)),
new TwigFunction('field_label', $this->getFieldLabel(...)),
new TwigFunction('field_help', $this->getFieldHelp(...)),
new TwigFunction('field_errors', $this->getFieldErrors(...)),
new TwigFunction('field_choices', $this->getFieldChoices(...)),
];
}
public function getFilters(): array
{
return [
new TwigFilter('humanize', [FormRenderer::class, 'humanize']),
new TwigFilter('form_encode_currency', [FormRenderer::class, 'encodeCurrency'], ['is_safe' => ['html'], 'needs_environment' => true]),
];
}
public function getTests(): array
{
return [
new TwigTest('selectedchoice', 'Symfony\Bridge\Twig\Extension\twig_is_selected_choice'),
new TwigTest('rootform', 'Symfony\Bridge\Twig\Extension\twig_is_root_form'),
];
}
public function getFieldName(FormView $view): string
{
$view->setRendered();
return $view->vars['full_name'];
}
public function getFieldValue(FormView $view): string|array
{
return $view->vars['value'];
}
public function getFieldLabel(FormView $view): ?string
{
if (false === $label = $view->vars['label']) {
return null;
}
if (!$label && $labelFormat = $view->vars['label_format']) {
$label = str_replace(['%id%', '%name%'], [$view->vars['id'], $view->vars['name']], $labelFormat);
} elseif (!$label) {
$label = ucfirst(strtolower(trim(preg_replace(['/([A-Z])/', '/[_\s]+/'], ['_$1', ' '], $view->vars['name']))));
}
return $this->createFieldTranslation(
$label,
$view->vars['label_translation_parameters'] ?: [],
$view->vars['translation_domain']
);
}
public function getFieldHelp(FormView $view): ?string
{
return $this->createFieldTranslation(
$view->vars['help'],
$view->vars['help_translation_parameters'] ?: [],
$view->vars['translation_domain']
);
}
/**
* @return string[]
*/
public function getFieldErrors(FormView $view): iterable
{
/** @var FormError $error */
foreach ($view->vars['errors'] as $error) {
yield $error->getMessage();
}
}
/**
* @return string[]|string[][]
*/
public function getFieldChoices(FormView $view): iterable
{
yield from $this->createFieldChoicesList($view->vars['choices'], $view->vars['choice_translation_domain']);
}
private function createFieldChoicesList(iterable $choices, string|false|null $translationDomain): iterable
{
foreach ($choices as $choice) {
if ($choice instanceof ChoiceGroupView) {
$translatableLabel = $this->createFieldTranslation($choice->label, [], $translationDomain);
yield $translatableLabel => $this->createFieldChoicesList($choice, $translationDomain);
continue;
}
/* @var ChoiceView $choice */
$translatableLabel = $this->createFieldTranslation($choice->label, $choice->labelTranslationParameters, $translationDomain);
yield $translatableLabel => $choice->value;
}
}
private function createFieldTranslation(TranslatableInterface|string|null $value, array $parameters, string|false|null $domain): ?string
{
if (!$this->translator || !$value || false === $domain) {
return null !== $value ? (string) $value : null;
}
if ($value instanceof TranslatableInterface) {
return $value->trans($this->translator);
}
return $this->translator->trans($value, $parameters, $domain);
}
}
/**
* Returns whether a choice is selected for a given form value.
*
* This is a function and not callable due to performance reasons.
*
* @see ChoiceView::isSelected()
*/
function twig_is_selected_choice(ChoiceView $choice, string|array|null $selectedValue): bool
{
if (\is_array($selectedValue)) {
return \in_array($choice->value, $selectedValue, true);
}
return $choice->value === $selectedValue;
}
/**
* @internal
*/
function twig_is_root_form(FormView $formView): bool
{
return null === $formView->parent;
}
/**
* @internal
*/
function twig_get_form_parent(FormView $formView): ?FormView
{
return $formView->parent;
}

View File

@@ -0,0 +1,40 @@
<?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\Bridge\Twig\Extension;
use Psr\Container\ContainerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class HtmlSanitizerExtension extends AbstractExtension
{
public function __construct(
private ContainerInterface $sanitizers,
private string $defaultSanitizer = 'default',
) {
}
public function getFilters(): array
{
return [
new TwigFilter('sanitize_html', $this->sanitize(...), ['is_safe' => ['html']]),
];
}
public function sanitize(string $html, ?string $sanitizer = null): string
{
return $this->sanitizers->get($sanitizer ?? $this->defaultSanitizer)->sanitize($html);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UrlHelper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Twig extension for the Symfony HttpFoundation component.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class HttpFoundationExtension extends AbstractExtension
{
private UrlHelper $urlHelper;
public function __construct(UrlHelper $urlHelper)
{
$this->urlHelper = $urlHelper;
}
public function getFunctions(): array
{
return [
new TwigFunction('absolute_url', $this->generateAbsoluteUrl(...)),
new TwigFunction('relative_path', $this->generateRelativePath(...)),
];
}
/**
* Returns the absolute URL for the given absolute or relative path.
*
* This method returns the path unchanged if no request is available.
*
* @see Request::getUriForPath()
*/
public function generateAbsoluteUrl(string $path): string
{
return $this->urlHelper->getAbsoluteUrl($path);
}
/**
* Returns a relative path based on the current Request.
*
* This method returns the path unchanged if no request is available.
*
* @see Request::getRelativeUriForPath()
*/
public function generateRelativePath(string $path): string
{
return $this->urlHelper->getRelativePath($path);
}
}

View File

@@ -0,0 +1,39 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\HttpKernel\Controller\ControllerReference;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Provides integration with the HttpKernel component.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class HttpKernelExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('render', [HttpKernelRuntime::class, 'renderFragment'], ['is_safe' => ['html']]),
new TwigFunction('render_*', [HttpKernelRuntime::class, 'renderFragmentStrategy'], ['is_safe' => ['html']]),
new TwigFunction('fragment_uri', [HttpKernelRuntime::class, 'generateFragmentUri']),
new TwigFunction('controller', [self::class, 'controller']),
];
}
public static function controller(string $controller, array $attributes = [], array $query = []): ControllerReference
{
return new ControllerReference($controller, $attributes, $query);
}
}

View File

@@ -0,0 +1,65 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\HttpKernel\Controller\ControllerReference;
use Symfony\Component\HttpKernel\Fragment\FragmentHandler;
use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface;
/**
* Provides integration with the HttpKernel component.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class HttpKernelRuntime
{
private FragmentHandler $handler;
private ?FragmentUriGeneratorInterface $fragmentUriGenerator;
public function __construct(FragmentHandler $handler, ?FragmentUriGeneratorInterface $fragmentUriGenerator = null)
{
$this->handler = $handler;
$this->fragmentUriGenerator = $fragmentUriGenerator;
}
/**
* Renders a fragment.
*
* @see FragmentHandler::render()
*/
public function renderFragment(string|ControllerReference $uri, array $options = []): string
{
$strategy = $options['strategy'] ?? 'inline';
unset($options['strategy']);
return $this->handler->render($uri, $strategy, $options);
}
/**
* Renders a fragment.
*
* @see FragmentHandler::render()
*/
public function renderFragmentStrategy(string $strategy, string|ControllerReference $uri, array $options = []): string
{
return $this->handler->render($uri, $strategy, $options);
}
public function generateFragmentUri(ControllerReference $controller, bool $absolute = false, bool $strict = true, bool $sign = true): string
{
if (null === $this->fragmentUriGenerator) {
throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FragmentUriGeneratorInterface::class, __METHOD__));
}
return $this->fragmentUriGenerator->generate($controller, null, $absolute, $strict, $sign);
}
}

View File

@@ -0,0 +1,28 @@
<?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\Bridge\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
final class ImportMapExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('importmap', [ImportMapRuntime::class, 'importmap'], ['is_safe' => ['html']]),
];
}
}

View File

@@ -0,0 +1,33 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
/**
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
class ImportMapRuntime
{
public function __construct(private readonly ImportMapRenderer $importMapRenderer)
{
}
public function importmap(string|array|null $entryPoint = 'app', array $attributes = []): string
{
if (null === $entryPoint) {
trigger_deprecation('symfony/twig-bridge', '6.4', 'Passing null as the first argument of the "importmap" Twig function is deprecated, pass an empty array if no entrypoints are desired.');
}
return $this->importMapRenderer->render($entryPoint, $attributes);
}
}

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\Bridge\Twig\Extension;
use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* LogoutUrlHelper provides generator functions for the logout URL to Twig.
*
* @author Jeremy Mikola <jmikola@gmail.com>
*/
final class LogoutUrlExtension extends AbstractExtension
{
private LogoutUrlGenerator $generator;
public function __construct(LogoutUrlGenerator $generator)
{
$this->generator = $generator;
}
public function getFunctions(): array
{
return [
new TwigFunction('logout_url', $this->getLogoutUrl(...)),
new TwigFunction('logout_path', $this->getLogoutPath(...)),
];
}
/**
* Generates the relative logout URL for the firewall.
*
* @param string|null $key The firewall key or null to use the current firewall key
*/
public function getLogoutPath(?string $key = null): string
{
return $this->generator->getLogoutPath($key);
}
/**
* Generates the absolute logout URL for the firewall.
*
* @param string|null $key The firewall key or null to use the current firewall key
*/
public function getLogoutUrl(?string $key = null): string
{
return $this->generator->getLogoutUrl($key);
}
}

View File

@@ -0,0 +1,57 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Stopwatch\StopwatchEvent;
use Twig\Extension\ProfilerExtension as BaseProfilerExtension;
use Twig\Profiler\Profile;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class ProfilerExtension extends BaseProfilerExtension
{
private ?Stopwatch $stopwatch;
/**
* @var \SplObjectStorage<Profile, StopwatchEvent>
*/
private \SplObjectStorage $events;
public function __construct(Profile $profile, ?Stopwatch $stopwatch = null)
{
parent::__construct($profile);
$this->stopwatch = $stopwatch;
$this->events = new \SplObjectStorage();
}
public function enter(Profile $profile): void
{
if ($this->stopwatch && $profile->isTemplate()) {
$this->events[$profile] = $this->stopwatch->start($profile->getName(), 'template');
}
parent::enter($profile);
}
public function leave(Profile $profile): void
{
parent::leave($profile);
if ($this->stopwatch && $profile->isTemplate()) {
$this->events[$profile]->stop();
unset($this->events[$profile]);
}
}
}

View File

@@ -0,0 +1,90 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\AbstractExtension;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Node;
use Twig\TwigFunction;
/**
* Provides integration of the Routing component with Twig.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class RoutingExtension extends AbstractExtension
{
private UrlGeneratorInterface $generator;
public function __construct(UrlGeneratorInterface $generator)
{
$this->generator = $generator;
}
public function getFunctions(): array
{
return [
new TwigFunction('url', $this->getUrl(...), ['is_safe_callback' => $this->isUrlGenerationSafe(...)]),
new TwigFunction('path', $this->getPath(...), ['is_safe_callback' => $this->isUrlGenerationSafe(...)]),
];
}
public function getPath(string $name, array $parameters = [], bool $relative = false): string
{
return $this->generator->generate($name, $parameters, $relative ? UrlGeneratorInterface::RELATIVE_PATH : UrlGeneratorInterface::ABSOLUTE_PATH);
}
public function getUrl(string $name, array $parameters = [], bool $schemeRelative = false): string
{
return $this->generator->generate($name, $parameters, $schemeRelative ? UrlGeneratorInterface::NETWORK_PATH : UrlGeneratorInterface::ABSOLUTE_URL);
}
/**
* Determines at compile time whether the generated URL will be safe and thus
* saving the unneeded automatic escaping for performance reasons.
*
* The URL generation process percent encodes non-alphanumeric characters. So there is no risk
* that malicious/invalid characters are part of the URL. The only character within a URL that
* must be escaped in html is the ampersand ("&") which separates query params. So we cannot mark
* the URL generation as always safe, but only when we are sure there won't be multiple query
* params. This is the case when there are none or only one constant parameter given.
* E.g. we know beforehand this will be safe:
* - path('route')
* - path('route', {'param': 'value'})
* But the following may not:
* - path('route', var)
* - path('route', {'param': ['val1', 'val2'] }) // a sub-array
* - path('route', {'param1': 'value1', 'param2': 'value2'})
* If param1 and param2 reference placeholder in the route, it would still be safe. But we don't know.
*
* @param Node $argsNode The arguments of the path/url function
*
* @return array An array with the contexts the URL is safe
*/
public function isUrlGenerationSafe(Node $argsNode): array
{
// support named arguments
$paramsNode = $argsNode->hasNode('parameters') ? $argsNode->getNode('parameters') : (
$argsNode->hasNode(1) ? $argsNode->getNode(1) : null
);
if (null === $paramsNode || $paramsNode instanceof ArrayExpression && \count($paramsNode) <= 2
&& (!$paramsNode->hasNode(1) || $paramsNode->getNode(1) instanceof ConstantExpression)
) {
return ['html'];
}
return [];
}
}

View File

@@ -0,0 +1,100 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Security\Acl\Voter\FieldVote;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
use Symfony\Component\Security\Http\Impersonate\ImpersonateUrlGenerator;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* SecurityExtension exposes security context features.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class SecurityExtension extends AbstractExtension
{
private ?AuthorizationCheckerInterface $securityChecker;
private ?ImpersonateUrlGenerator $impersonateUrlGenerator;
public function __construct(?AuthorizationCheckerInterface $securityChecker = null, ?ImpersonateUrlGenerator $impersonateUrlGenerator = null)
{
$this->securityChecker = $securityChecker;
$this->impersonateUrlGenerator = $impersonateUrlGenerator;
}
public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool
{
if (null === $this->securityChecker) {
return false;
}
if (null !== $field) {
$object = new FieldVote($object, $field);
}
try {
return $this->securityChecker->isGranted($role, $object);
} catch (AuthenticationCredentialsNotFoundException) {
return false;
}
}
public function getImpersonateExitUrl(?string $exitTo = null): string
{
if (null === $this->impersonateUrlGenerator) {
return '';
}
return $this->impersonateUrlGenerator->generateExitUrl($exitTo);
}
public function getImpersonateExitPath(?string $exitTo = null): string
{
if (null === $this->impersonateUrlGenerator) {
return '';
}
return $this->impersonateUrlGenerator->generateExitPath($exitTo);
}
public function getImpersonateUrl(string $identifier): string
{
if (null === $this->impersonateUrlGenerator) {
return '';
}
return $this->impersonateUrlGenerator->generateImpersonationUrl($identifier);
}
public function getImpersonatePath(string $identifier): string
{
if (null === $this->impersonateUrlGenerator) {
return '';
}
return $this->impersonateUrlGenerator->generateImpersonationPath($identifier);
}
public function getFunctions(): array
{
return [
new TwigFunction('is_granted', $this->isGranted(...)),
new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)),
new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)),
new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)),
new TwigFunction('impersonation_path', $this->getImpersonatePath(...)),
];
}
}

View File

@@ -0,0 +1,28 @@
<?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\Bridge\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*/
final class SerializerExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('serialize', [SerializerRuntime::class, 'serialize']),
];
}
}

View File

@@ -0,0 +1,33 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Serializer\SerializerInterface;
use Twig\Extension\RuntimeExtensionInterface;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*/
final class SerializerRuntime implements RuntimeExtensionInterface
{
private SerializerInterface $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
public function serialize(mixed $data, string $format = 'json', array $context = []): string
{
return $this->serializer->serialize($data, $format, $context);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Bridge\Twig\Extension;
use Symfony\Bridge\Twig\TokenParser\StopwatchTokenParser;
use Symfony\Component\Stopwatch\Stopwatch;
use Twig\Extension\AbstractExtension;
use Twig\TokenParser\TokenParserInterface;
/**
* Twig extension for the stopwatch helper.
*
* @author Wouter J <wouter@wouterj.nl>
*/
final class StopwatchExtension extends AbstractExtension
{
private ?Stopwatch $stopwatch;
private bool $enabled;
public function __construct(?Stopwatch $stopwatch = null, bool $enabled = true)
{
$this->stopwatch = $stopwatch;
$this->enabled = $enabled;
}
public function getStopwatch(): Stopwatch
{
return $this->stopwatch;
}
/**
* @return TokenParserInterface[]
*/
public function getTokenParsers(): array
{
return [
/*
* {% stopwatch foo %}
* Some stuff which will be recorded on the timeline
* {% endstopwatch %}
*/
new StopwatchTokenParser(null !== $this->stopwatch && $this->enabled),
];
}
}

View File

@@ -0,0 +1,136 @@
<?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\Bridge\Twig\Extension;
use Symfony\Bridge\Twig\NodeVisitor\TranslationDefaultDomainNodeVisitor;
use Symfony\Bridge\Twig\NodeVisitor\TranslationNodeVisitor;
use Symfony\Bridge\Twig\TokenParser\TransDefaultDomainTokenParser;
use Symfony\Bridge\Twig\TokenParser\TransTokenParser;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Contracts\Translation\TranslatorTrait;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
// Help opcache.preload discover always-needed symbols
class_exists(TranslatorInterface::class);
class_exists(TranslatorTrait::class);
/**
* Provides integration of the Translation component with Twig.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class TranslationExtension extends AbstractExtension
{
private ?TranslatorInterface $translator;
private ?TranslationNodeVisitor $translationNodeVisitor;
public function __construct(?TranslatorInterface $translator = null, ?TranslationNodeVisitor $translationNodeVisitor = null)
{
$this->translator = $translator;
$this->translationNodeVisitor = $translationNodeVisitor;
}
public function getTranslator(): TranslatorInterface
{
if (null === $this->translator) {
if (!interface_exists(TranslatorInterface::class)) {
throw new \LogicException(sprintf('You cannot use the "%s" if the Translation Contracts are not available. Try running "composer require symfony/translation".', __CLASS__));
}
$this->translator = new class() implements TranslatorInterface {
use TranslatorTrait;
};
}
return $this->translator;
}
public function getFunctions(): array
{
return [
new TwigFunction('t', $this->createTranslatable(...)),
];
}
public function getFilters(): array
{
return [
new TwigFilter('trans', $this->trans(...)),
];
}
public function getTokenParsers(): array
{
return [
// {% trans %}Symfony is great!{% endtrans %}
new TransTokenParser(),
// {% trans_default_domain "foobar" %}
new TransDefaultDomainTokenParser(),
];
}
public function getNodeVisitors(): array
{
return [$this->getTranslationNodeVisitor(), new TranslationDefaultDomainNodeVisitor()];
}
public function getTranslationNodeVisitor(): TranslationNodeVisitor
{
return $this->translationNodeVisitor ?: $this->translationNodeVisitor = new TranslationNodeVisitor();
}
/**
* @param array|string $arguments Can be the locale as a string when $message is a TranslatableInterface
*/
public function trans(string|\Stringable|TranslatableInterface|null $message, array|string $arguments = [], ?string $domain = null, ?string $locale = null, ?int $count = null): string
{
if ($message instanceof TranslatableInterface) {
if ([] !== $arguments && !\is_string($arguments)) {
throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be a locale passed as a string when the message is a "%s", "%s" given.', __METHOD__, TranslatableInterface::class, get_debug_type($arguments)));
}
if ($message instanceof TranslatableMessage && '' === $message->getMessage()) {
return '';
}
return $message->trans($this->getTranslator(), $locale ?? (\is_string($arguments) ? $arguments : null));
}
if (!\is_array($arguments)) {
throw new \TypeError(sprintf('Unless the message is a "%s", argument 2 passed to "%s()" must be an array of parameters, "%s" given.', TranslatableInterface::class, __METHOD__, get_debug_type($arguments)));
}
if ('' === $message = (string) $message) {
return '';
}
if (null !== $count) {
$arguments['%count%'] = $count;
}
return $this->getTranslator()->trans($message, $arguments, $domain, $locale);
}
public function createTranslatable(string $message, array $parameters = [], ?string $domain = null): TranslatableMessage
{
if (!class_exists(TranslatableMessage::class)) {
throw new \LogicException(sprintf('You cannot use the "%s" as the Translation Component is not installed. Try running "composer require symfony/translation".', __CLASS__));
}
return new TranslatableMessage($message, $parameters, $domain);
}
}

View File

@@ -0,0 +1,130 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\WebLink\GenericLinkProvider;
use Symfony\Component\WebLink\Link;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Twig extension for the Symfony WebLink component.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class WebLinkExtension extends AbstractExtension
{
private RequestStack $requestStack;
public function __construct(RequestStack $requestStack)
{
$this->requestStack = $requestStack;
}
public function getFunctions(): array
{
return [
new TwigFunction('link', $this->link(...)),
new TwigFunction('preload', $this->preload(...)),
new TwigFunction('dns_prefetch', $this->dnsPrefetch(...)),
new TwigFunction('preconnect', $this->preconnect(...)),
new TwigFunction('prefetch', $this->prefetch(...)),
new TwigFunction('prerender', $this->prerender(...)),
];
}
/**
* Adds a "Link" HTTP header.
*
* @param string $rel The relation type (e.g. "preload", "prefetch", "prerender" or "dns-prefetch")
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]")
*
* @return string The relation URI
*/
public function link(string $uri, string $rel, array $attributes = []): string
{
if (!$request = $this->requestStack->getMainRequest()) {
return $uri;
}
$link = new Link($rel, $uri);
foreach ($attributes as $key => $value) {
$link = $link->withAttribute($key, $value);
}
$linkProvider = $request->attributes->get('_links', new GenericLinkProvider());
$request->attributes->set('_links', $linkProvider->withLink($link));
return $uri;
}
/**
* Preloads a resource.
*
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['crossorigin' => 'use-credentials']")
*
* @return string The path of the asset
*/
public function preload(string $uri, array $attributes = []): string
{
return $this->link($uri, 'preload', $attributes);
}
/**
* Resolves a resource origin as early as possible.
*
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]")
*
* @return string The path of the asset
*/
public function dnsPrefetch(string $uri, array $attributes = []): string
{
return $this->link($uri, 'dns-prefetch', $attributes);
}
/**
* Initiates a early connection to a resource (DNS resolution, TCP handshake, TLS negotiation).
*
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]")
*
* @return string The path of the asset
*/
public function preconnect(string $uri, array $attributes = []): string
{
return $this->link($uri, 'preconnect', $attributes);
}
/**
* Indicates to the client that it should prefetch this resource.
*
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]")
*
* @return string The path of the asset
*/
public function prefetch(string $uri, array $attributes = []): string
{
return $this->link($uri, 'prefetch', $attributes);
}
/**
* Indicates to the client that it should prerender this resource .
*
* @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]")
*
* @return string The path of the asset
*/
public function prerender(string $uri, array $attributes = []): string
{
return $this->link($uri, 'prerender', $attributes);
}
}

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\Bridge\Twig\Extension;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\TransitionBlockerList;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* WorkflowExtension.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Carlos Pereira De Amorim <carlos@shauri.fr>
*/
final class WorkflowExtension extends AbstractExtension
{
private Registry $workflowRegistry;
public function __construct(Registry $workflowRegistry)
{
$this->workflowRegistry = $workflowRegistry;
}
public function getFunctions(): array
{
return [
new TwigFunction('workflow_can', $this->canTransition(...)),
new TwigFunction('workflow_transitions', $this->getEnabledTransitions(...)),
new TwigFunction('workflow_transition', $this->getEnabledTransition(...)),
new TwigFunction('workflow_has_marked_place', $this->hasMarkedPlace(...)),
new TwigFunction('workflow_marked_places', $this->getMarkedPlaces(...)),
new TwigFunction('workflow_metadata', $this->getMetadata(...)),
new TwigFunction('workflow_transition_blockers', $this->buildTransitionBlockerList(...)),
];
}
/**
* Returns true if the transition is enabled.
*/
public function canTransition(object $subject, string $transitionName, ?string $name = null): bool
{
return $this->workflowRegistry->get($subject, $name)->can($subject, $transitionName);
}
/**
* Returns all enabled transitions.
*
* @return Transition[]
*/
public function getEnabledTransitions(object $subject, ?string $name = null): array
{
return $this->workflowRegistry->get($subject, $name)->getEnabledTransitions($subject);
}
public function getEnabledTransition(object $subject, string $transition, ?string $name = null): ?Transition
{
return $this->workflowRegistry->get($subject, $name)->getEnabledTransition($subject, $transition);
}
/**
* Returns true if the place is marked.
*/
public function hasMarkedPlace(object $subject, string $placeName, ?string $name = null): bool
{
return $this->workflowRegistry->get($subject, $name)->getMarking($subject)->has($placeName);
}
/**
* Returns marked places.
*
* @return string[]|int[]
*/
public function getMarkedPlaces(object $subject, bool $placesNameOnly = true, ?string $name = null): array
{
$places = $this->workflowRegistry->get($subject, $name)->getMarking($subject)->getPlaces();
if ($placesNameOnly) {
return array_keys($places);
}
return $places;
}
/**
* Returns the metadata for a specific subject.
*
* @param string|Transition|null $metadataSubject Use null to get workflow metadata
* Use a string (the place name) to get place metadata
* Use a Transition instance to get transition metadata
*/
public function getMetadata(object $subject, string $key, string|Transition|null $metadataSubject = null, ?string $name = null): mixed
{
return $this
->workflowRegistry
->get($subject, $name)
->getMetadataStore()
->getMetadata($key, $metadataSubject)
;
}
public function buildTransitionBlockerList(object $subject, string $transitionName, ?string $name = null): TransitionBlockerList
{
$workflow = $this->workflowRegistry->get($subject, $name);
return $workflow->buildTransitionBlockerList($subject, $transitionName);
}
}

View File

@@ -0,0 +1,58 @@
<?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\Bridge\Twig\Extension;
use Symfony\Component\Yaml\Dumper as YamlDumper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* Provides integration of the Yaml component with Twig.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class YamlExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('yaml_encode', $this->encode(...)),
new TwigFilter('yaml_dump', $this->dump(...)),
];
}
public function encode(mixed $input, int $inline = 0, int $dumpObjects = 0): string
{
static $dumper;
$dumper ??= new YamlDumper();
if (\defined('Symfony\Component\Yaml\Yaml::DUMP_OBJECT')) {
return $dumper->dump($input, $inline, 0, $dumpObjects);
}
return $dumper->dump($input, $inline, 0, false, $dumpObjects);
}
public function dump(mixed $value, int $inline = 0, int $dumpObjects = 0): string
{
if (\is_resource($value)) {
return '%Resource%';
}
if (\is_array($value) || \is_object($value)) {
return '%'.\gettype($value).'% '.$this->encode($value, $inline, $dumpObjects);
}
return $this->encode($value, $inline, $dumpObjects);
}
}

View File

@@ -0,0 +1,169 @@
<?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\Bridge\Twig\Form;
use Symfony\Component\Form\AbstractRendererEngine;
use Symfony\Component\Form\FormView;
use Twig\Environment;
use Twig\Template;
/**
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class TwigRendererEngine extends AbstractRendererEngine
{
private Environment $environment;
private Template $template;
public function __construct(array $defaultThemes, Environment $environment)
{
parent::__construct($defaultThemes);
$this->environment = $environment;
}
public function renderBlock(FormView $view, mixed $resource, string $blockName, array $variables = []): string
{
$cacheKey = $view->vars[self::CACHE_KEY_VAR];
$context = $variables + $this->environment->getGlobals();
ob_start();
// By contract,This method can only be called after getting the resource
// (which is passed to the method). Getting a resource for the first time
// (with an empty cache) is guaranteed to invoke loadResourcesFromTheme(),
// where the property $template is initialized.
// We do not call renderBlock here to avoid too many nested level calls
// (XDebug limits the level to 100 by default)
$this->template->displayBlock($blockName, $context, $this->resources[$cacheKey]);
return ob_get_clean();
}
/**
* Loads the cache with the resource for a given block name.
*
* This implementation eagerly loads all blocks of the themes assigned to the given view
* and all of its ancestors views. This is necessary, because Twig receives the
* list of blocks later. At that point, all blocks must already be loaded, for the
* case that the function "block()" is used in the Twig template.
*
* @see getResourceForBlock()
*/
protected function loadResourceForBlockName(string $cacheKey, FormView $view, string $blockName): bool
{
// The caller guarantees that $this->resources[$cacheKey][$block] is
// not set, but it doesn't have to check whether $this->resources[$cacheKey]
// is set. If $this->resources[$cacheKey] is set, all themes for this
// $cacheKey are already loaded (due to the eager population, see doc comment).
if (isset($this->resources[$cacheKey])) {
// As said in the previous, the caller guarantees that
// $this->resources[$cacheKey][$block] is not set. Since the themes are
// already loaded, it can only be a non-existing block.
$this->resources[$cacheKey][$blockName] = false;
return false;
}
// Recursively try to find the block in the themes assigned to $view,
// then of its parent view, then of the parent view of the parent and so on.
// When the root view is reached in this recursion, also the default
// themes are taken into account.
// Check each theme whether it contains the searched block
if (isset($this->themes[$cacheKey])) {
for ($i = \count($this->themes[$cacheKey]) - 1; $i >= 0; --$i) {
$this->loadResourcesFromTheme($cacheKey, $this->themes[$cacheKey][$i]);
// CONTINUE LOADING (see doc comment)
}
}
// Check the default themes once we reach the root view without success
if (!$view->parent) {
if (!isset($this->useDefaultThemes[$cacheKey]) || $this->useDefaultThemes[$cacheKey]) {
for ($i = \count($this->defaultThemes) - 1; $i >= 0; --$i) {
$this->loadResourcesFromTheme($cacheKey, $this->defaultThemes[$i]);
// CONTINUE LOADING (see doc comment)
}
}
}
// Proceed with the themes of the parent view
if ($view->parent) {
$parentCacheKey = $view->parent->vars[self::CACHE_KEY_VAR];
if (!isset($this->resources[$parentCacheKey])) {
$this->loadResourceForBlockName($parentCacheKey, $view->parent, $blockName);
}
// EAGER CACHE POPULATION (see doc comment)
foreach ($this->resources[$parentCacheKey] as $nestedBlockName => $resource) {
if (!isset($this->resources[$cacheKey][$nestedBlockName])) {
$this->resources[$cacheKey][$nestedBlockName] = $resource;
}
}
}
// Even though we loaded the themes, it can happen that none of them
// contains the searched block
if (!isset($this->resources[$cacheKey][$blockName])) {
// Cache that we didn't find anything to speed up further accesses
$this->resources[$cacheKey][$blockName] = false;
}
return false !== $this->resources[$cacheKey][$blockName];
}
/**
* Loads the resources for all blocks in a theme.
*
* @param mixed $theme The theme to load the block from. This parameter
* is passed by reference, because it might be necessary
* to initialize the theme first. Any changes made to
* this variable will be kept and be available upon
* further calls to this method using the same theme.
*
* @return void
*/
protected function loadResourcesFromTheme(string $cacheKey, mixed &$theme)
{
if (!$theme instanceof Template) {
$theme = $this->environment->load($theme)->unwrap();
}
// Store the first Template instance that we find so that
// we can call displayBlock() later on. It doesn't matter *which*
// template we use for that, since we pass the used blocks manually
// anyway.
$this->template ??= $theme;
// Use a separate variable for the inheritance traversal, because
// theme is a reference and we don't want to change it.
$currentTheme = $theme;
$context = $this->environment->getGlobals();
// The do loop takes care of template inheritance.
// Add blocks from all templates in the inheritance tree, but avoid
// overriding blocks already set.
do {
foreach ($currentTheme->getBlocks() as $block => $blockData) {
if (!isset($this->resources[$cacheKey][$block])) {
// The resource given back is the key to the bucket that
// contains this block.
$this->resources[$cacheKey][$block] = $blockData;
}
}
} while (false !== $currentTheme = $currentTheme->getParent($context));
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2004-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,91 @@
<?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\Bridge\Twig\Mime;
use League\HTMLToMarkdown\HtmlConverterInterface;
use Symfony\Component\Mime\BodyRendererInterface;
use Symfony\Component\Mime\Exception\InvalidArgumentException;
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
use Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface;
use Symfony\Component\Mime\HtmlToTextConverter\LeagueHtmlToMarkdownConverter;
use Symfony\Component\Mime\Message;
use Symfony\Component\Translation\LocaleSwitcher;
use Twig\Environment;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class BodyRenderer implements BodyRendererInterface
{
private Environment $twig;
private array $context;
private HtmlToTextConverterInterface $converter;
private ?LocaleSwitcher $localeSwitcher = null;
public function __construct(Environment $twig, array $context = [], ?HtmlToTextConverterInterface $converter = null, ?LocaleSwitcher $localeSwitcher = null)
{
$this->twig = $twig;
$this->context = $context;
$this->converter = $converter ?: (interface_exists(HtmlConverterInterface::class) ? new LeagueHtmlToMarkdownConverter() : new DefaultHtmlToTextConverter());
$this->localeSwitcher = $localeSwitcher;
}
public function render(Message $message): void
{
if (!$message instanceof TemplatedEmail) {
return;
}
if (null === $message->getTextTemplate() && null === $message->getHtmlTemplate()) {
// email has already been rendered
return;
}
$callback = function () use ($message) {
$messageContext = $message->getContext();
if (isset($messageContext['email'])) {
throw new InvalidArgumentException(sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', get_debug_type($message)));
}
$vars = array_merge($this->context, $messageContext, [
'email' => new WrappedTemplatedEmail($this->twig, $message),
]);
if ($template = $message->getTextTemplate()) {
$message->text($this->twig->render($template, $vars));
}
if ($template = $message->getHtmlTemplate()) {
$message->html($this->twig->render($template, $vars));
}
$message->markAsRendered();
// if text body is empty, compute one from the HTML body
if (!$message->getTextBody() && null !== $html = $message->getHtmlBody()) {
$text = $this->converter->convert(\is_resource($html) ? stream_get_contents($html) : $html, $message->getHtmlCharset());
$message->text($text, $message->getHtmlCharset());
}
};
$locale = $message->getLocale();
if ($locale && $this->localeSwitcher) {
$this->localeSwitcher->runWithLocale($locale, $callback);
return;
}
$callback();
}
}

View File

@@ -0,0 +1,280 @@
<?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\Bridge\Twig\Mime;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\DataPart;
use Twig\Extra\CssInliner\CssInlinerExtension;
use Twig\Extra\Inky\InkyExtension;
use Twig\Extra\Markdown\MarkdownExtension;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class NotificationEmail extends TemplatedEmail
{
public const IMPORTANCE_URGENT = 'urgent';
public const IMPORTANCE_HIGH = 'high';
public const IMPORTANCE_MEDIUM = 'medium';
public const IMPORTANCE_LOW = 'low';
private string $theme = 'default';
private array $context = [
'importance' => self::IMPORTANCE_LOW,
'content' => '',
'exception' => false,
'action_text' => null,
'action_url' => null,
'markdown' => false,
'raw' => false,
'footer_text' => 'Notification email sent by Symfony',
];
private bool $rendered = false;
public function __construct(?Headers $headers = null, ?AbstractPart $body = null)
{
$missingPackages = [];
if (!class_exists(CssInlinerExtension::class)) {
$missingPackages['twig/cssinliner-extra'] = 'CSS Inliner';
}
if (!class_exists(InkyExtension::class)) {
$missingPackages['twig/inky-extra'] = 'Inky';
}
if ($missingPackages) {
throw new \LogicException(sprintf('You cannot use "%s" if the "%s" Twig extension%s not available. Try running "%s".', static::class, implode('" and "', $missingPackages), \count($missingPackages) > 1 ? 's are' : ' is', 'composer require '.implode(' ', array_keys($missingPackages))));
}
parent::__construct($headers, $body);
}
/**
* Creates a NotificationEmail instance that is appropriate to send to normal (non-admin) users.
*/
public static function asPublicEmail(?Headers $headers = null, ?AbstractPart $body = null): self
{
$email = new static($headers, $body);
$email->markAsPublic();
return $email;
}
/**
* @return $this
*/
public function markAsPublic(): static
{
$this->context['importance'] = null;
$this->context['footer_text'] = null;
return $this;
}
/**
* @return $this
*/
public function markdown(string $content): static
{
if (!class_exists(MarkdownExtension::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the Markdown Twig extension is not available. Try running "composer require twig/markdown-extra".', __METHOD__));
}
$this->context['markdown'] = true;
return $this->content($content);
}
/**
* @return $this
*/
public function content(string $content, bool $raw = false): static
{
$this->context['content'] = $content;
$this->context['raw'] = $raw;
return $this;
}
/**
* @return $this
*/
public function action(string $text, string $url): static
{
$this->context['action_text'] = $text;
$this->context['action_url'] = $url;
return $this;
}
/**
* @return $this
*/
public function importance(string $importance): static
{
$this->context['importance'] = $importance;
return $this;
}
/**
* @return $this
*/
public function exception(\Throwable|FlattenException $exception): static
{
$exceptionAsString = $this->getExceptionAsString($exception);
$this->context['exception'] = true;
$this->addPart(new DataPart($exceptionAsString, 'exception.txt', 'text/plain'));
$this->importance(self::IMPORTANCE_URGENT);
if (!$this->getSubject()) {
$this->subject($exception->getMessage());
}
return $this;
}
/**
* @return $this
*/
public function theme(string $theme): static
{
$this->theme = $theme;
return $this;
}
public function getTextTemplate(): ?string
{
if ($template = parent::getTextTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.txt.twig';
}
public function getHtmlTemplate(): ?string
{
if ($template = parent::getHtmlTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.html.twig';
}
/**
* @return $this
*/
public function context(array $context): static
{
$parentContext = [];
foreach ($context as $key => $value) {
if (\array_key_exists($key, $this->context)) {
$this->context[$key] = $value;
} else {
$parentContext[$key] = $value;
}
}
parent::context($parentContext);
return $this;
}
public function getContext(): array
{
return array_merge($this->context, parent::getContext());
}
public function isRendered(): bool
{
return $this->rendered;
}
public function markAsRendered(): void
{
parent::markAsRendered();
$this->rendered = true;
}
public function getPreparedHeaders(): Headers
{
$headers = parent::getPreparedHeaders();
$importance = $this->context['importance'] ?? self::IMPORTANCE_LOW;
$this->priority($this->determinePriority($importance));
if ($this->context['importance']) {
$headers->setHeaderBody('Text', 'Subject', sprintf('[%s] %s', strtoupper($importance), $this->getSubject()));
}
return $headers;
}
private function determinePriority(string $importance): int
{
return match ($importance) {
self::IMPORTANCE_URGENT => self::PRIORITY_HIGHEST,
self::IMPORTANCE_HIGH => self::PRIORITY_HIGH,
self::IMPORTANCE_MEDIUM => self::PRIORITY_NORMAL,
default => self::PRIORITY_LOW,
};
}
private function getExceptionAsString(\Throwable|FlattenException $exception): string
{
if (class_exists(FlattenException::class)) {
$exception = $exception instanceof FlattenException ? $exception : FlattenException::createFromThrowable($exception);
return $exception->getAsString();
}
$message = $exception::class;
if ('' !== $exception->getMessage()) {
$message .= ': '.$exception->getMessage();
}
$message .= ' in '.$exception->getFile().':'.$exception->getLine()."\n";
$message .= "Stack trace:\n".$exception->getTraceAsString()."\n\n";
return rtrim($message);
}
/**
* @internal
*/
public function __serialize(): array
{
return [$this->context, $this->theme, $this->rendered, parent::__serialize()];
}
/**
* @internal
*/
public function __unserialize(array $data): void
{
if (4 === \count($data)) {
[$this->context, $this->theme, $this->rendered, $parentData] = $data;
} elseif (3 === \count($data)) {
[$this->context, $this->theme, $parentData] = $data;
} else {
// Backwards compatibility for deserializing data structures that were serialized without the theme
[$this->context, $parentData] = $data;
}
parent::__unserialize($parentData);
}
}

View File

@@ -0,0 +1,116 @@
<?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\Bridge\Twig\Mime;
use Symfony\Component\Mime\Email;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class TemplatedEmail extends Email
{
private ?string $htmlTemplate = null;
private ?string $textTemplate = null;
private ?string $locale = null;
private array $context = [];
/**
* @return $this
*/
public function textTemplate(?string $template): static
{
$this->textTemplate = $template;
return $this;
}
/**
* @return $this
*/
public function htmlTemplate(?string $template): static
{
$this->htmlTemplate = $template;
return $this;
}
/**
* @return $this
*/
public function locale(?string $locale): static
{
$this->locale = $locale;
return $this;
}
public function getTextTemplate(): ?string
{
return $this->textTemplate;
}
public function getHtmlTemplate(): ?string
{
return $this->htmlTemplate;
}
public function getLocale(): ?string
{
return $this->locale;
}
/**
* @return $this
*/
public function context(array $context): static
{
$this->context = $context;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function isRendered(): bool
{
return null === $this->htmlTemplate && null === $this->textTemplate;
}
public function markAsRendered(): void
{
$this->textTemplate = null;
$this->htmlTemplate = null;
$this->context = [];
}
/**
* @internal
*/
public function __serialize(): array
{
return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale];
}
/**
* @internal
*/
public function __unserialize(array $data): void
{
[$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data;
$this->locale = $data[4] ?? null;
parent::__unserialize($parentData);
}
}

View File

@@ -0,0 +1,203 @@
<?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\Bridge\Twig\Mime;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\File;
use Twig\Environment;
/**
* @internal
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class WrappedTemplatedEmail
{
private Environment $twig;
private TemplatedEmail $message;
public function __construct(Environment $twig, TemplatedEmail $message)
{
$this->twig = $twig;
$this->message = $message;
}
public function toName(): string
{
return $this->message->getTo()[0]->getName();
}
/**
* @param string $image A Twig path to the image file. It's recommended to define
* some Twig namespace for email images (e.g. '@email/images/logo.png').
* @param string|null $contentType The media type (i.e. MIME type) of the image file (e.g. 'image/png').
* Some email clients require this to display embedded images.
*/
public function image(string $image, ?string $contentType = null): string
{
$file = $this->twig->getLoader()->getSourceContext($image);
$body = $file->getPath() ? new File($file->getPath()) : $file->getCode();
$this->message->addPart((new DataPart($body, $image, $contentType))->asInline());
return 'cid:'.$image;
}
/**
* @param string $file A Twig path to the file. It's recommended to define
* some Twig namespace for email files (e.g. '@email/files/contract.pdf').
* @param string|null $name A custom file name that overrides the original name of the attached file
* @param string|null $contentType The media type (i.e. MIME type) of the file (e.g. 'application/pdf').
* Some email clients require this to display attached files.
*/
public function attach(string $file, ?string $name = null, ?string $contentType = null): void
{
$file = $this->twig->getLoader()->getSourceContext($file);
$body = $file->getPath() ? new File($file->getPath()) : $file->getCode();
$this->message->addPart(new DataPart($body, $name, $contentType));
}
/**
* @return $this
*/
public function setSubject(string $subject): static
{
$this->message->subject($subject);
return $this;
}
public function getSubject(): ?string
{
return $this->message->getSubject();
}
/**
* @return $this
*/
public function setReturnPath(string $address): static
{
$this->message->returnPath($address);
return $this;
}
public function getReturnPath(): string
{
return $this->message->getReturnPath();
}
/**
* @return $this
*/
public function addFrom(string $address, string $name = ''): static
{
$this->message->addFrom(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getFrom(): array
{
return $this->message->getFrom();
}
/**
* @return $this
*/
public function addReplyTo(string $address): static
{
$this->message->addReplyTo($address);
return $this;
}
/**
* @return Address[]
*/
public function getReplyTo(): array
{
return $this->message->getReplyTo();
}
/**
* @return $this
*/
public function addTo(string $address, string $name = ''): static
{
$this->message->addTo(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getTo(): array
{
return $this->message->getTo();
}
/**
* @return $this
*/
public function addCc(string $address, string $name = ''): static
{
$this->message->addCc(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getCc(): array
{
return $this->message->getCc();
}
/**
* @return $this
*/
public function addBcc(string $address, string $name = ''): static
{
$this->message->addBcc(new Address($address, $name));
return $this;
}
/**
* @return Address[]
*/
public function getBcc(): array
{
return $this->message->getBcc();
}
/**
* @return $this
*/
public function setPriority(int $priority): static
{
$this->message->priority($priority);
return $this;
}
public function getPriority(): int
{
return $this->message->getPriority();
}
}

View File

@@ -0,0 +1,102 @@
<?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\Bridge\Twig\Node;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\Variable\LocalVariable;
use Twig\Node\Node;
/**
* @author Julien Galenski <julien.galenski@gmail.com>
*/
#[YieldReady]
final class DumpNode extends Node
{
private LocalVariable|string $varPrefix;
public function __construct(LocalVariable|string $varPrefix, ?Node $values, int $lineno, ?string $tag = null)
{
$nodes = [];
if (null !== $values) {
$nodes['values'] = $values;
}
if (class_exists(FirstClassTwigCallableReady::class)) {
parent::__construct($nodes, [], $lineno);
} else {
parent::__construct($nodes, [], $lineno, $tag);
}
$this->varPrefix = $varPrefix;
}
public function compile(Compiler $compiler): void
{
if ($this->varPrefix instanceof LocalVariable) {
$varPrefix = $this->varPrefix->getAttribute('name');
} else {
$varPrefix = $this->varPrefix;
}
$compiler
->write("if (\$this->env->isDebug()) {\n")
->indent();
if (!$this->hasNode('values')) {
// remove embedded templates (macros) from the context
$compiler
->write(sprintf('$%svars = [];'."\n", $varPrefix))
->write(sprintf('foreach ($context as $%1$skey => $%1$sval) {'."\n", $varPrefix))
->indent()
->write(sprintf('if (!$%sval instanceof \Twig\Template) {'."\n", $varPrefix))
->indent()
->write(sprintf('$%1$svars[$%1$skey] = $%1$sval;'."\n", $varPrefix))
->outdent()
->write("}\n")
->outdent()
->write("}\n")
->addDebugInfo($this)
->write(sprintf('\Symfony\Component\VarDumper\VarDumper::dump($%svars);'."\n", $varPrefix));
} elseif (($values = $this->getNode('values')) && 1 === $values->count()) {
$compiler
->addDebugInfo($this)
->write('\Symfony\Component\VarDumper\VarDumper::dump(')
->subcompile($values->getNode(0))
->raw(");\n");
} else {
$compiler
->addDebugInfo($this)
->write('\Symfony\Component\VarDumper\VarDumper::dump(['."\n")
->indent();
foreach ($values as $node) {
$compiler->write('');
if ($node->hasAttribute('name')) {
$compiler
->string($node->getAttribute('name'))
->raw(' => ');
}
$compiler
->subcompile($node)
->raw(",\n");
}
$compiler
->outdent()
->write("]);\n");
}
$compiler
->outdent()
->write("}\n");
}
}

View File

@@ -0,0 +1,49 @@
<?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\Bridge\Twig\Node;
use Symfony\Component\Form\FormRenderer;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Node;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class FormThemeNode extends Node
{
public function __construct(Node $form, Node $resources, int $lineno, ?string $tag = null, bool $only = false)
{
if (class_exists(FirstClassTwigCallableReady::class)) {
parent::__construct(['form' => $form, 'resources' => $resources], ['only' => $only], $lineno);
} else {
parent::__construct(['form' => $form, 'resources' => $resources], ['only' => $only], $lineno, $tag);
}
}
public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write('$this->env->getRuntime(')
->string(FormRenderer::class)
->raw(')->setTheme(')
->subcompile($this->getNode('form'))
->raw(', ')
->subcompile($this->getNode('resources'))
->raw(', ')
->raw(false === $this->getAttribute('only') ? 'true' : 'false')
->raw(");\n");
}
}

View File

@@ -0,0 +1,45 @@
<?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\Bridge\Twig\Node;
use Twig\Compiler;
use Twig\Node\Expression\FunctionExpression;
/**
* Compiles a call to {@link \Symfony\Component\Form\FormRendererInterface::renderBlock()}.
*
* The function name is used as block name. For example, if the function name
* is "foo", the block "foo" will be rendered.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
final class RenderBlockNode extends FunctionExpression
{
public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);
$arguments = iterator_to_array($this->getNode('arguments'));
$compiler->write('$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->renderBlock(');
if (isset($arguments[0])) {
$compiler->subcompile($arguments[0]);
$compiler->raw(', \''.$this->getAttribute('name').'\'');
if (isset($arguments[1])) {
$compiler->raw(', ');
$compiler->subcompile($arguments[1]);
}
}
$compiler->raw(')');
}
}

View File

@@ -0,0 +1,116 @@
<?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\Bridge\Twig\Node;
use Twig\Compiler;
use Twig\Extension\CoreExtension;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FunctionExpression;
/**
* @author Bernhard Schussek <bschussek@gmail.com>
*/
final class SearchAndRenderBlockNode extends FunctionExpression
{
public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);
$compiler->raw('$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(');
preg_match('/_([^_]+)$/', $this->getAttribute('name'), $matches);
$arguments = iterator_to_array($this->getNode('arguments'));
$blockNameSuffix = $matches[1];
if (isset($arguments[0])) {
$compiler->subcompile($arguments[0]);
$compiler->raw(', \''.$blockNameSuffix.'\'');
if (isset($arguments[1])) {
if ('label' === $blockNameSuffix) {
// The "label" function expects the label in the second and
// the variables in the third argument
$label = $arguments[1];
$variables = $arguments[2] ?? null;
$lineno = $label->getTemplateLine();
if ($label instanceof ConstantExpression) {
// If the label argument is given as a constant, we can either
// strip it away if it is empty, or integrate it into the array
// of variables at compile time.
$labelIsExpression = false;
// Only insert the label into the array if it is not empty
if (null !== $label->getAttribute('value') && false !== $label->getAttribute('value') && '' !== (string) $label->getAttribute('value')) {
$originalVariables = $variables;
$variables = new ArrayExpression([], $lineno);
$labelKey = new ConstantExpression('label', $lineno);
if (null !== $originalVariables) {
foreach ($originalVariables->getKeyValuePairs() as $pair) {
// Don't copy the original label attribute over if it exists
if ((string) $labelKey !== (string) $pair['key']) {
$variables->addElement($pair['value'], $pair['key']);
}
}
}
// Insert the label argument into the array
$variables->addElement($label, $labelKey);
}
} else {
// The label argument is not a constant, but some kind of
// expression. This expression needs to be evaluated at runtime.
// Depending on the result (whether it is null or not), the
// label in the arguments should take precedence over the label
// in the attributes or not.
$labelIsExpression = true;
}
} else {
// All other functions than "label" expect the variables
// in the second argument
$label = null;
$variables = $arguments[1];
$labelIsExpression = false;
}
if (null !== $variables || $labelIsExpression) {
$compiler->raw(', ');
if (null !== $variables) {
$compiler->subcompile($variables);
}
if ($labelIsExpression) {
if (null !== $variables) {
$compiler->raw(' + ');
}
// Check at runtime whether the label is empty.
// If not, add it to the array at runtime.
if (method_exists(CoreExtension::class, 'testEmpty')) {
$compiler->raw('(CoreExtension::testEmpty($_label_ = ');
} else {
$compiler->raw('(twig_test_empty($_label_ = ');
}
$compiler->subcompile($label);
$compiler->raw(') ? [] : ["label" => $_label_])');
}
}
}
}
$compiler->raw(')');
}
}

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\Bridge\Twig\Node;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\Variable\LocalVariable;
use Twig\Node\Node;
/**
* Represents a stopwatch node.
*
* @author Wouter J <wouter@wouterj.nl>
*/
#[YieldReady]
final class StopwatchNode extends Node
{
/**
* @param AssignNameExpression|LocalVariable $var
*/
public function __construct(Node $name, Node $body, $var, int $lineno = 0, ?string $tag = null)
{
if (!$var instanceof AssignNameExpression && !$var instanceof LocalVariable) {
throw new \TypeError(sprintf('Expected an instance of "%s" or "%s", but got "%s".', AssignNameExpression::class, LocalVariable::class, get_debug_type($var)));
}
if (class_exists(FirstClassTwigCallableReady::class)) {
parent::__construct(['body' => $body, 'name' => $name, 'var' => $var], [], $lineno);
} else {
parent::__construct(['body' => $body, 'name' => $name, 'var' => $var], [], $lineno, $tag);
}
}
public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write('')
->subcompile($this->getNode('var'))
->raw(' = ')
->subcompile($this->getNode('name'))
->write(";\n")
->write("\$this->env->getExtension('Symfony\Bridge\Twig\Extension\StopwatchExtension')->getStopwatch()->start(")
->subcompile($this->getNode('var'))
->raw(", 'template');\n")
->subcompile($this->getNode('body'))
->write("\$this->env->getExtension('Symfony\Bridge\Twig\Extension\StopwatchExtension')->getStopwatch()->stop(")
->subcompile($this->getNode('var'))
->raw(");\n")
;
}
}

View File

@@ -0,0 +1,39 @@
<?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\Bridge\Twig\Node;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Node;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class TransDefaultDomainNode extends Node
{
public function __construct(AbstractExpression $expr, int $lineno = 0, ?string $tag = null)
{
if (class_exists(FirstClassTwigCallableReady::class)) {
parent::__construct(['expr' => $expr], [], $lineno);
} else {
parent::__construct(['expr' => $expr], [], $lineno, $tag);
}
}
public function compile(Compiler $compiler): void
{
// noop as this node is just a marker for TranslationDefaultDomainNodeVisitor
}
}

View File

@@ -0,0 +1,139 @@
<?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\Bridge\Twig\Node;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Node;
use Twig\Node\TextNode;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class TransNode extends Node
{
public function __construct(Node $body, ?Node $domain = null, ?AbstractExpression $count = null, ?AbstractExpression $vars = null, ?AbstractExpression $locale = null, int $lineno = 0, ?string $tag = null)
{
$nodes = ['body' => $body];
if (null !== $domain) {
$nodes['domain'] = $domain;
}
if (null !== $count) {
$nodes['count'] = $count;
}
if (null !== $vars) {
$nodes['vars'] = $vars;
}
if (null !== $locale) {
$nodes['locale'] = $locale;
}
if (class_exists(FirstClassTwigCallableReady::class)) {
parent::__construct($nodes, [], $lineno);
} else {
parent::__construct($nodes, [], $lineno, $tag);
}
}
public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);
$defaults = new ArrayExpression([], -1);
if ($this->hasNode('vars') && ($vars = $this->getNode('vars')) instanceof ArrayExpression) {
$defaults = $this->getNode('vars');
$vars = null;
}
[$msg, $defaults] = $this->compileString($this->getNode('body'), $defaults, (bool) $vars);
$display = class_exists(YieldReady::class) ? 'yield' : 'echo';
$compiler
->write($display.' $this->env->getExtension(\'Symfony\Bridge\Twig\Extension\TranslationExtension\')->trans(')
->subcompile($msg)
;
$compiler->raw(', ');
if (null !== $vars) {
$compiler
->raw('array_merge(')
->subcompile($defaults)
->raw(', ')
->subcompile($this->getNode('vars'))
->raw(')')
;
} else {
$compiler->subcompile($defaults);
}
$compiler->raw(', ');
if (!$this->hasNode('domain')) {
$compiler->repr('messages');
} else {
$compiler->subcompile($this->getNode('domain'));
}
if ($this->hasNode('locale')) {
$compiler
->raw(', ')
->subcompile($this->getNode('locale'))
;
} elseif ($this->hasNode('count')) {
$compiler->raw(', null');
}
if ($this->hasNode('count')) {
$compiler
->raw(', ')
->subcompile($this->getNode('count'))
;
}
$compiler->raw(");\n");
}
private function compileString(Node $body, ArrayExpression $vars, bool $ignoreStrictCheck = false): array
{
if ($body instanceof ConstantExpression) {
$msg = $body->getAttribute('value');
} elseif ($body instanceof TextNode) {
$msg = $body->getAttribute('data');
} else {
return [$body, $vars];
}
preg_match_all('/(?<!%)%([^%]+)%/', $msg, $matches);
foreach ($matches[1] as $var) {
$key = new ConstantExpression('%'.$var.'%', $body->getTemplateLine());
if (!$vars->hasElement($key)) {
if ('count' === $var && $this->hasNode('count')) {
$vars->addElement($this->getNode('count'), $key);
} else {
$varExpr = class_exists(ContextVariable::class) ? new ContextVariable($var, $body->getTemplateLine()) : new NameExpression($var, $body->getTemplateLine());
$varExpr->setAttribute('ignore_strict_check', $ignoreStrictCheck);
$vars->addElement($varExpr, $key);
}
}
}
return [new ConstantExpression(str_replace('%%', '%', trim($msg)), $body->getTemplateLine()), $vars];
}
}

View File

@@ -0,0 +1,95 @@
<?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\Bridge\Twig\NodeVisitor;
/**
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
*/
class Scope
{
private ?self $parent;
private array $data = [];
private bool $left = false;
public function __construct(?self $parent = null)
{
$this->parent = $parent;
}
/**
* Opens a new child scope.
*/
public function enter(): self
{
return new self($this);
}
/**
* Closes current scope and returns parent one.
*/
public function leave(): ?self
{
$this->left = true;
return $this->parent;
}
/**
* Stores data into current scope.
*
* @return $this
*
* @throws \LogicException
*/
public function set(string $key, mixed $value): static
{
if ($this->left) {
throw new \LogicException('Left scope is not mutable.');
}
$this->data[$key] = $value;
return $this;
}
/**
* Tests if a data is visible from current scope.
*/
public function has(string $key): bool
{
if (\array_key_exists($key, $this->data)) {
return true;
}
if (null === $this->parent) {
return false;
}
return $this->parent->has($key);
}
/**
* Returns data visible from current scope.
*/
public function get(string $key, mixed $default = null): mixed
{
if (\array_key_exists($key, $this->data)) {
return $this->data[$key];
}
if (null === $this->parent) {
return $default;
}
return $this->parent->get($key, $default);
}
}

View File

@@ -0,0 +1,133 @@
<?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\Bridge\Twig\NodeVisitor;
use Symfony\Bridge\Twig\Node\TransDefaultDomainNode;
use Symfony\Bridge\Twig\Node\TransNode;
use Twig\Environment;
use Twig\Node\BlockNode;
use Twig\Node\EmptyNode;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\AssignContextVariable;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Node\SetNode;
use Twig\NodeVisitor\NodeVisitorInterface;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class TranslationDefaultDomainNodeVisitor implements NodeVisitorInterface
{
private Scope $scope;
public function __construct()
{
$this->scope = new Scope();
}
public function enterNode(Node $node, Environment $env): Node
{
if ($node instanceof BlockNode || $node instanceof ModuleNode) {
$this->scope = $this->scope->enter();
}
if ($node instanceof TransDefaultDomainNode) {
if ($node->getNode('expr') instanceof ConstantExpression) {
$this->scope->set('domain', $node->getNode('expr'));
return $node;
} else {
$var = $this->getVarName();
$name = class_exists(AssignContextVariable::class) ? new AssignContextVariable($var, $node->getTemplateLine()) : new AssignNameExpression($var, $node->getTemplateLine());
$this->scope->set('domain', class_exists(ContextVariable::class) ? new ContextVariable($var, $node->getTemplateLine()) : new NameExpression($var, $node->getTemplateLine()));
if (class_exists(Nodes::class)) {
return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine());
} else {
return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine());
}
}
}
if (!$this->scope->has('domain')) {
return $node;
}
if ($node instanceof FilterExpression && 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value'))) {
$arguments = $node->getNode('arguments');
if ($arguments instanceof EmptyNode) {
$arguments = new Nodes();
$node->setNode('arguments', $arguments);
}
if ($this->isNamedArguments($arguments)) {
if (!$arguments->hasNode('domain') && !$arguments->hasNode(1)) {
$arguments->setNode('domain', $this->scope->get('domain'));
}
} elseif (!$arguments->hasNode(1)) {
if (!$arguments->hasNode(0)) {
$arguments->setNode(0, new ArrayExpression([], $node->getTemplateLine()));
}
$arguments->setNode(1, $this->scope->get('domain'));
}
} elseif ($node instanceof TransNode) {
if (!$node->hasNode('domain')) {
$node->setNode('domain', $this->scope->get('domain'));
}
}
return $node;
}
public function leaveNode(Node $node, Environment $env): ?Node
{
if ($node instanceof TransDefaultDomainNode) {
return null;
}
if ($node instanceof BlockNode || $node instanceof ModuleNode) {
$this->scope = $this->scope->leave();
}
return $node;
}
public function getPriority(): int
{
return -10;
}
private function isNamedArguments(Node $arguments): bool
{
foreach ($arguments as $name => $node) {
if (!\is_int($name)) {
return true;
}
}
return false;
}
private function getVarName(): string
{
return sprintf('__internal_%s', hash('sha256', uniqid(mt_rand(), true), false));
}
}

View File

@@ -0,0 +1,194 @@
<?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\Bridge\Twig\NodeVisitor;
use Symfony\Bridge\Twig\Node\TransNode;
use Twig\Environment;
use Twig\Node\Expression\Binary\ConcatBinary;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\Node;
use Twig\NodeVisitor\NodeVisitorInterface;
/**
* TranslationNodeVisitor extracts translation messages.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class TranslationNodeVisitor implements NodeVisitorInterface
{
public const UNDEFINED_DOMAIN = '_undefined';
private bool $enabled = false;
private array $messages = [];
public function enable(): void
{
$this->enabled = true;
$this->messages = [];
}
public function disable(): void
{
$this->enabled = false;
$this->messages = [];
}
public function getMessages(): array
{
return $this->messages;
}
public function enterNode(Node $node, Environment $env): Node
{
if (!$this->enabled) {
return $node;
}
if (
$node instanceof FilterExpression
&& 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value'))
&& $node->getNode('node') instanceof ConstantExpression
) {
// extract constant nodes with a trans filter
$this->messages[] = [
$node->getNode('node')->getAttribute('value'),
$this->getReadDomainFromArguments($node->getNode('arguments'), 1),
];
} elseif (
$node instanceof FunctionExpression
&& 't' === $node->getAttribute('name')
) {
$nodeArguments = $node->getNode('arguments');
if ($nodeArguments->getIterator()->current() instanceof ConstantExpression) {
$this->messages[] = [
$this->getReadMessageFromArguments($nodeArguments, 0),
$this->getReadDomainFromArguments($nodeArguments, 2),
];
}
} elseif ($node instanceof TransNode) {
// extract trans nodes
$this->messages[] = [
$node->getNode('body')->getAttribute('data'),
$node->hasNode('domain') ? $this->getReadDomainFromNode($node->getNode('domain')) : null,
];
} elseif (
$node instanceof FilterExpression
&& 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value'))
&& $node->getNode('node') instanceof ConcatBinary
&& $message = $this->getConcatValueFromNode($node->getNode('node'), null)
) {
$this->messages[] = [
$message,
$this->getReadDomainFromArguments($node->getNode('arguments'), 1),
];
}
return $node;
}
public function leaveNode(Node $node, Environment $env): ?Node
{
return $node;
}
public function getPriority(): int
{
return 0;
}
private function getReadMessageFromArguments(Node $arguments, int $index): ?string
{
if ($arguments->hasNode('message')) {
$argument = $arguments->getNode('message');
} elseif ($arguments->hasNode($index)) {
$argument = $arguments->getNode($index);
} else {
return null;
}
return $this->getReadMessageFromNode($argument);
}
private function getReadMessageFromNode(Node $node): ?string
{
if ($node instanceof ConstantExpression) {
return $node->getAttribute('value');
}
return null;
}
private function getReadDomainFromArguments(Node $arguments, int $index): ?string
{
if ($arguments->hasNode('domain')) {
$argument = $arguments->getNode('domain');
} elseif ($arguments->hasNode($index)) {
$argument = $arguments->getNode($index);
} else {
return null;
}
return $this->getReadDomainFromNode($argument);
}
private function getReadDomainFromNode(Node $node): ?string
{
if ($node instanceof ConstantExpression) {
return $node->getAttribute('value');
}
if (
$node instanceof FunctionExpression
&& 'constant' === $node->getAttribute('name')
) {
$nodeArguments = $node->getNode('arguments');
if ($nodeArguments->getIterator()->current() instanceof ConstantExpression) {
$constantName = $nodeArguments->getIterator()->current()->getAttribute('value');
if (\defined($constantName)) {
$value = \constant($constantName);
if (\is_string($value)) {
return $value;
}
}
}
}
return self::UNDEFINED_DOMAIN;
}
private function getConcatValueFromNode(Node $node, ?string $value): ?string
{
if ($node instanceof ConcatBinary) {
foreach ($node as $nextNode) {
if ($nextNode instanceof ConcatBinary) {
$nextValue = $this->getConcatValueFromNode($nextNode, $value);
if (null === $nextValue) {
return null;
}
$value .= $nextValue;
} elseif ($nextNode instanceof ConstantExpression) {
$value .= $nextNode->getAttribute('value');
} else {
// this is a node we cannot process (variable, or translation in translation)
return null;
}
}
} elseif ($node instanceof ConstantExpression) {
$value .= $node->getAttribute('value');
}
return $value;
}
}

View File

@@ -0,0 +1,13 @@
Twig Bridge
===========
The Twig bridge provides integration for [Twig](https://twig.symfony.com/) with
various Symfony components.
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@@ -0,0 +1 @@
{% extends "@email/zurb_2/notification/body.html.twig" %}

View File

@@ -0,0 +1 @@
{% extends "@email/zurb_2/notification/body.txt.twig" %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
{% apply inky_to_html|inline_css %}
<html>
<head>
<style>
{% block style %}
{{ source("@email/zurb_2/main.css") }}
{{ source("@email/zurb_2/notification/local.css") }}
{% endblock %}
</style>
</head>
<body>
<spacer size="32"></spacer>
<wrapper class="body">
<container class="body_{{ ("urgent" == importance ? "alert" : ("high" == importance ? "warning" : "default")) }}">
<spacer size="16"></spacer>
<row>
<columns large="12" small="12">
{% block lead %}
{% if importance is not null %}<small><strong>{{ importance|upper }}</strong></small>{% endif %}
<p class="lead">
{{ email.subject }}
</p>
{% endblock %}
{% block content %}
{% if markdown %}
{{ include('@email/zurb_2/notification/content_markdown.html.twig') }}
{% else %}
{{ raw ? content|raw : content|nl2br }}
{% endif %}
{% endblock %}
{% block action %}
{% if action_url %}
<spacer size="16"></spacer>
<button href="{{ action_url }}">{{ action_text }}</button>
{% endif %}
{% endblock %}
{% block exception %}
{% if exception %}
<spacer size="16"></spacer>
<p><em>Exception stack trace attached.</em></p>
{% endif %}
{% endblock %}
</columns>
</row>
<wrapper class="secondary">
<spacer size="16"></spacer>
{% block footer %}
{% if footer_text is defined and footer_text is not null %}
<row>
<columns small="12" large="6">
{% block footer_content %}
<p><small>{{ footer_text }}</small></p>
{% endblock %}
</columns>
</row>
{% endif %}
{% endblock %}
</wrapper>
</container>
</wrapper>
</body>
</html>
{% endapply %}

View File

@@ -0,0 +1,20 @@
{% block lead %}
{{ email.subject }}
{% endblock %}
{% block content %}
{{ content }}
{% endblock %}
{% block action %}
{% if action_url %}
{{ action_text }}: {{ action_url }}
{% endif %}
{% endblock %}
{% block exception %}
{% if exception %}
Exception stack trace attached.
{{ exception }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1 @@
{{ content|markdown_to_html }}

View File

@@ -0,0 +1,19 @@
body {
background: #f3f3f3;
}
.wrapper.secondary {
background: #f3f3f3;
}
.container.body_alert {
border-top: 8px solid #ec5840;
}
.container.body_warning {
border-top: 8px solid #ffae00;
}
.container.body_default {
border-top: 8px solid #aaaaaa;
}

View File

@@ -0,0 +1,71 @@
{% use "bootstrap_3_layout.html.twig" %}
{% block form_start -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-horizontal')|trim}) %}
{{- parent() -}}
{%- endblock form_start %}
{# Labels #}
{% block form_label -%}
{%- if label is same as(false) -%}
<div class="{{ block('form_label_class') }}"></div>
{%- else -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ block('form_label_class'))|trim}) -%}
{{- parent() -}}
{%- endif -%}
{%- endblock form_label %}
{% block form_label_class -%}
col-sm-2
{%- endblock form_label_class %}
{# Rows #}
{% block form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group' ~ ((not compound or force_error|default(false)) and not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
{##}</div>
{%- endblock form_row %}
{% block submit_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock submit_row %}
{% block reset_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock reset_row %}
{% block form_group_class -%}
col-sm-10
{%- endblock form_group_class %}
{% block checkbox_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group' ~ (not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>{#--#}
</div>
{%- endblock checkbox_row %}

View File

@@ -0,0 +1,220 @@
{% use "bootstrap_base_layout.html.twig" %}
{# Widgets #}
{% block form_widget_simple -%}
{% if type is not defined or type not in ['file', 'hidden'] %}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block button_widget -%}
{%- set attr = attr|merge({class: (attr.class|default('btn-default') ~ ' btn')|trim}) -%}
{{- parent() -}}
{%- endblock button_widget %}
{% block money_widget -%}
{% set prepend = not (money_pattern starts with '{{') %}
{% set append = not (money_pattern ends with '}}') %}
{% if prepend or append %}
<div class="input-group">
{% if prepend %}
<span class="input-group-addon">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
{{- block('form_widget_simple') -}}
{% if append %}
<span class="input-group-addon">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
</div>
{% else %}
{{- block('form_widget_simple') -}}
{% endif %}
{%- endblock money_widget %}
{% block checkbox_widget -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{% if 'checkbox-inline' in parent_label_class %}
{{- form_label(form, null, { widget: parent() }) -}}
{% else -%}
<div class="checkbox">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- endif -%}
{%- endblock checkbox_widget %}
{% block radio_widget -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if 'radio-inline' in parent_label_class -%}
{{- form_label(form, null, { widget: parent() }) -}}
{%- else -%}
<div class="radio">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- endif -%}
{%- endblock radio_widget %}
{% block choice_widget_collapsed -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
{{- parent() -}}
{%- endblock choice_widget_collapsed %}
{# Labels #}
{% block form_label -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' control-label')|trim}) -%}
{{- parent() -}}
{%- endblock form_label %}
{% block choice_label -%}
{# remove the checkbox-inline and radio-inline class, it's only useful for embed labels #}
{%- set label_attr = label_attr|merge({class: label_attr.class|default('')|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) -%}
{{- block('form_label') -}}
{% endblock %}
{% block checkbox_label -%}
{%- set label_attr = label_attr|merge({'for': id}) -%}
{{- block('checkbox_radio_label') -}}
{%- endblock checkbox_label %}
{% block radio_label -%}
{%- set label_attr = label_attr|merge({'for': id}) -%}
{{- block('checkbox_radio_label') -}}
{%- endblock radio_label %}
{% block checkbox_radio_label -%}
{# Do not display the label if widget is not defined in order to prevent double label rendering #}
{%- if widget is defined -%}
{%- if required -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) -%}
{%- endif -%}
{%- if parent_label_class is defined -%}
{% set embed_label_classes = parent_label_class|split(' ')|filter(class => class in ['checkbox-inline', 'radio-inline']) %}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ embed_label_classes|join(' '))|trim}) -%}
{% endif %}
{%- if label is not same as(false) and label is empty -%}
{%- if label_format is not empty -%}
{%- set label = label_format|replace({
'%name%': name,
'%id%': id,
}) -%}
{%- else -%}
{% set label = name|humanize %}
{%- endif -%}
{%- endif -%}
<label{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{#- if statement must be kept on the same line, to force the space between widget and label -#}
{{- widget|raw }} {% if label is not same as(false) -%}
{%- if translation_domain is same as(false) -%}
{%- if label_html is same as(false) -%}
{{ label -}}
{%- else -%}
{{ label|raw -}}
{%- endif -%}
{%- else -%}
{%- if label_html is same as(false) -%}
{{ label|trans(label_translation_parameters, translation_domain) -}}
{%- else -%}
{{ label|trans(label_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
{%- endif -%}
</label>
{%- endif -%}
{%- endblock checkbox_radio_label %}
{# Rows #}
{% block form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group' ~ ((not compound or force_error|default(false)) and not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) }} {# -#}
{{ form_widget(form, widget_attr) }} {# -#}
{{- form_help(form) -}}
{{ form_errors(form) }} {# -#}
</div> {# -#}
{%- endblock form_row %}
{% block button_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
</div>
{%- endblock button_row %}
{% block choice_row -%}
{% set force_error = true %}
{{- block('form_row') }}
{%- endblock choice_row %}
{% block date_row -%}
{% set force_error = true %}
{{- block('form_row') }}
{%- endblock date_row %}
{% block time_row -%}
{% set force_error = true %}
{{- block('form_row') }}
{%- endblock time_row %}
{% block datetime_row -%}
{% set force_error = true %}
{{- block('form_row') }}
{%- endblock datetime_row %}
{% block checkbox_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group' ~ (not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
{%- endblock checkbox_row %}
{% block radio_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group' ~ (not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
{%- endblock radio_row %}
{# Errors #}
{% block form_errors -%}
{% if errors|length > 0 -%}
{% if form is not rootform %}<span class="help-block">{% else %}<div class="alert alert-danger">{% endif %}
<ul class="list-unstyled">
{%- for error in errors -%}
<li><span class="glyphicon glyphicon-exclamation-sign"></span> {{ error.message }}</li>
{%- endfor -%}
</ul>
{% if form is not rootform %}</span>{% else %}</div>{% endif %}
{%- endif %}
{%- endblock form_errors %}
{# Help #}
{% block form_help -%}
{%- if help is not empty -%}
{%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ ' help-block')|trim}) -%}
<span id="{{ id }}_help"{% with { attr: help_attr } %}{{ block('attributes') }}{% endwith %}>
{%- if translation_domain is same as(false) -%}
{%- if help_html is same as(false) -%}
{{- help -}}
{%- else -%}
{{- help|raw -}}
{%- endif -%}
{%- else -%}
{%- if help_html is same as(false) -%}
{{- help|trans(help_translation_parameters, translation_domain) -}}
{%- else -%}
{{- help|trans(help_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
</span>
{%- endif -%}
{%- endblock form_help %}

View File

@@ -0,0 +1,88 @@
{% use "bootstrap_4_layout.html.twig" %}
{# Labels #}
{% block form_label -%}
{%- if label is same as(false) -%}
<div class="{{ block('form_label_class') }}"></div>
{%- else -%}
{%- if expanded is not defined or not expanded -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label')|trim}) -%}
{%- endif -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ block('form_label_class'))|trim}) -%}
{{- parent() -}}
{%- endif -%}
{%- endblock form_label %}
{% block form_label_class -%}
col-sm-2
{%- endblock form_label_class %}
{# Rows #}
{% block form_row -%}
{%- if expanded is defined and expanded -%}
{{ block('fieldset_form_row') }}
{%- else -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group row' ~ ((not compound or force_error|default(false)) and not valid ? ' is-invalid'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
</div>
{##}</div>
{%- endif -%}
{%- endblock form_row %}
{% block fieldset_form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<fieldset{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
<div class="row{% if (not compound or force_error|default(false)) and not valid %} is-invalid{% endif %}">
{{- form_label(form) -}}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
</div>
{##}</fieldset>
{%- endblock fieldset_form_row %}
{% block submit_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock submit_row %}
{% block reset_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock reset_row %}
{% block form_group_class -%}
col-sm-10
{%- endblock form_group_class %}
{% block checkbox_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
{{- form_help(form) -}}
</div>{#--#}
</div>
{%- endblock checkbox_row %}

View File

@@ -0,0 +1,319 @@
{% use "bootstrap_base_layout.html.twig" %}
{# Widgets #}
{% block money_widget -%}
{%- set prepend = not (money_pattern starts with '{{') -%}
{%- set append = not (money_pattern ends with '}}') -%}
{%- if prepend or append -%}
<div class="input-group {{ group_class|default('') }}">
{%- if prepend -%}
<div class="input-group-prepend">
<span class="input-group-text">{{ money_pattern|form_encode_currency }}</span>
</div>
{%- endif -%}
{{- block('form_widget_simple') -}}
{%- if append -%}
<div class="input-group-append">
<span class="input-group-text">{{ money_pattern|form_encode_currency }}</span>
</div>
{%- endif -%}
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock money_widget %}
{% block datetime_widget -%}
{%- if widget != 'single_text' and not valid -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control is-invalid')|trim}) -%}
{% set valid = true %}
{%- endif -%}
{{- parent() -}}
{%- endblock datetime_widget %}
{% block date_widget -%}
{%- if widget != 'single_text' and not valid -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control is-invalid')|trim}) -%}
{% set valid = true %}
{%- endif -%}
{{- parent() -}}
{%- endblock date_widget %}
{% block time_widget -%}
{%- if widget != 'single_text' and not valid -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control is-invalid')|trim}) -%}
{% set valid = true %}
{%- endif -%}
{{- parent() -}}
{%- endblock time_widget %}
{% block dateinterval_widget -%}
{%- if widget != 'single_text' and not valid -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control is-invalid')|trim}) -%}
{% set valid = true %}
{%- endif -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-inline')|trim}) -%}
<div {{ block('widget_container_attributes') }}>
{%- if with_years -%}
<div class="col-auto">
{{ form_label(form.years) }}
{{ form_widget(form.years) }}
</div>
{%- endif -%}
{%- if with_months -%}
<div class="col-auto">
{{ form_label(form.months) }}
{{ form_widget(form.months) }}
</div>
{%- endif -%}
{%- if with_weeks -%}
<div class="col-auto">
{{ form_label(form.weeks) }}
{{ form_widget(form.weeks) }}
</div>
{%- endif -%}
{%- if with_days -%}
<div class="col-auto">
{{ form_label(form.days) }}
{{ form_widget(form.days) }}
</div>
{%- endif -%}
{%- if with_hours -%}
<div class="col-auto">
{{ form_label(form.hours) }}
{{ form_widget(form.hours) }}
</div>
{%- endif -%}
{%- if with_minutes -%}
<div class="col-auto">
{{ form_label(form.minutes) }}
{{ form_widget(form.minutes) }}
</div>
{%- endif -%}
{%- if with_seconds -%}
<div class="col-auto">
{{ form_label(form.seconds) }}
{{ form_widget(form.seconds) }}
</div>
{%- endif -%}
{%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%}
</div>
{%- endif -%}
{%- endblock dateinterval_widget %}
{% block percent_widget -%}
{%- if symbol -%}
<div class="input-group">
{{- block('form_widget_simple') -}}
<div class="input-group-append">
<span class="input-group-text">{{ symbol|default('%') }}</span>
</div>
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock percent_widget %}
{% block file_widget -%}
<{{ element|default('div') }} class="custom-file">
{%- set type = type|default('file') -%}
{%- set input_lang = 'en' -%}
{% if app is defined and app.request is defined %}{%- set input_lang = app.request.locale -%}{%- endif -%}
{%- set attr = {lang: input_lang} | merge(attr) -%}
{{- block('form_widget_simple') -}}
{%- set label_attr = label_attr|merge({ class: (label_attr.class|default('') ~ ' custom-file-label')|trim })|filter((value, key) => key != 'id') -%}
<label for="{{ form.vars.id }}" {% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{%- if attr.placeholder is defined and attr.placeholder is not none -%}
{{- translation_domain is same as(false) ? attr.placeholder : attr.placeholder|trans({}, translation_domain) -}}
{%- endif -%}
</label>
</{{ element|default('div') }}>
{% endblock %}
{% block form_widget_simple -%}
{%- if type is not defined or type != 'hidden' -%}
{%- set className = ' form-control' -%}
{%- if type|default('') == 'file' -%}
{%- set className = ' custom-file-input' -%}
{%- elseif type|default('') == 'range' -%}
{%- set className = ' form-control-range' -%}
{%- endif -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ className)|trim}) -%}
{%- endif -%}
{%- if type is defined and (type == 'range' or type == 'color') %}
{# Attribute "required" is not supported #}
{%- set required = false -%}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block widget_attributes -%}
{%- if not valid -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %}
{%- endif -%}
{{ parent() }}
{%- endblock widget_attributes %}
{% block button_widget -%}
{%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%}
{{- parent() -}}
{%- endblock button_widget %}
{% block submit_widget -%}
{%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%}
{{- parent() -}}
{%- endblock submit_widget %}
{% block checkbox_widget -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if 'checkbox-custom' in parent_label_class -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' custom-control-input')|trim}) -%}
<div class="custom-control custom-checkbox{{ 'checkbox-inline' in parent_label_class ? ' custom-control-inline' }}">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- elseif 'switch-custom' in parent_label_class -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' custom-control-input')|trim}) -%}
<div class="custom-control custom-switch{{ 'switch-inline' in parent_label_class ? ' custom-control-inline' }}">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%}
<div class="form-check{{ 'checkbox-inline' in parent_label_class ? ' form-check-inline' }}">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- endif -%}
{%- endblock checkbox_widget %}
{% block radio_widget -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if 'radio-custom' in parent_label_class -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' custom-control-input')|trim}) -%}
<div class="custom-control custom-radio{{ 'radio-inline' in parent_label_class ? ' custom-control-inline' }}">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%}
<div class="form-check{{ 'radio-inline' in parent_label_class ? ' form-check-inline' }}">
{{- form_label(form, null, { widget: parent() }) -}}
</div>
{%- endif -%}
{%- endblock radio_widget %}
{% block choice_widget_collapsed -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
{{- parent() -}}
{%- endblock choice_widget_collapsed %}
{% block choice_widget_expanded -%}
<div {{ block('widget_container_attributes') }}>
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
valid: valid,
}) -}}
{% endfor -%}
</div>
{%- endblock choice_widget_expanded %}
{# Labels #}
{% block form_label -%}
{% if label is not same as(false) -%}
{%- if compound is defined and compound -%}
{%- set element = 'legend' -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label')|trim}) -%}
{%- else -%}
{%- set label_attr = label_attr|merge({for: id}) -%}
{%- endif -%}
{% if required -%}
{% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) %}
{%- endif -%}
<{{ element|default('label') }}{% if label_attr %}{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}>
{{- block('form_label_content') -}}
{% block form_label_errors %}{{- form_errors(form) -}}{% endblock form_label_errors %}</{{ element|default('label') }}>
{%- else -%}
{%- if errors|length > 0 -%}
<div id="{{ id }}_errors" class="mb-2">
{{- form_errors(form) -}}
</div>
{%- endif -%}
{%- endif -%}
{%- endblock form_label %}
{% block checkbox_radio_label -%}
{#- Do not display the label if widget is not defined in order to prevent double label rendering -#}
{%- if widget is defined -%}
{% set is_parent_custom = parent_label_class is defined and ('checkbox-custom' in parent_label_class or 'radio-custom' in parent_label_class or 'switch-custom' in parent_label_class) %}
{% set is_custom = label_attr.class is defined and ('checkbox-custom' in label_attr.class or 'radio-custom' in label_attr.class or 'switch-custom' in label_attr.class) %}
{%- if is_parent_custom or is_custom -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' custom-control-label')|trim}) -%}
{%- else %}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-check-label')|trim}) -%}
{%- endif %}
{%- if not compound -%}
{% set label_attr = label_attr|merge({'for': id}) %}
{%- endif -%}
{%- if required -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) -%}
{%- endif -%}
{%- if parent_label_class is defined -%}
{% set embed_label_classes = parent_label_class|split(' ')|filter(class => class in ['checkbox-inline', 'radio-inline']) %}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ embed_label_classes|join(' '))|trim}) -%}
{% endif %}
{{ widget|raw }}
<label{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{%- if label is not same as(false) -%}
{{- block('form_label_content') -}}
{%- endif -%}
{{- form_errors(form) -}}
</label>
{%- endif -%}
{%- endblock checkbox_radio_label %}
{# Rows #}
{% block form_row -%}
{%- if compound is defined and compound -%}
{%- set element = 'fieldset' -%}
{%- endif -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<{{ element|default('div') }}{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
</{{ element|default('div') }}>
{%- endblock form_row %}
{# Errors #}
{% block form_errors -%}
{%- if errors|length > 0 -%}
<span class="{% if form is not rootform %}invalid-feedback{% else %}alert alert-danger{% endif %} d-block">
{%- for error in errors -%}
<span class="d-block">
<span class="form-error-icon badge badge-danger text-uppercase">{{ 'Error'|trans({}, 'validators') }}</span> <span class="form-error-message">{{ error.message }}</span>
</span>
{%- endfor -%}
</span>
{%- endif %}
{%- endblock form_errors %}
{# Help #}
{% block form_help -%}
{%- if help is not empty -%}
{%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ ' form-text text-muted')|trim}) -%}
<small id="{{ id }}_help"{% with { attr: help_attr } %}{{ block('attributes') }}{% endwith %}>
{{- block('form_help_content') -}}
</small>
{%- endif -%}
{%- endblock form_help %}

View File

@@ -0,0 +1,130 @@
{% use "bootstrap_5_layout.html.twig" %}
{# Labels #}
{% block form_label -%}
{%- if label is same as(false) -%}
<div class="{{ block('form_label_class') }}"></div>
{%- else -%}
{%- set row_class = row_class|default(row_attr.class|default('')) -%}
{%- if 'form-floating' not in row_class and 'input-group' not in row_class -%}
{%- if expanded is not defined or not expanded -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label')|trim}) -%}
{%- endif -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ block('form_label_class'))|trim}) -%}
{%- endif -%}
{{- parent() -}}
{%- endif -%}
{%- endblock form_label %}
{% block form_label_class -%}
col-sm-2
{%- endblock form_label_class %}
{# Rows #}
{% block form_row -%}
{%- if expanded is defined and expanded -%}
{{ block('fieldset_form_row') }}
{%- else -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
{%- set row_class = row_class|default(row_attr.class|default('mb-3')) -%}
{%- set is_form_floating = is_form_floating|default('form-floating' in row_class) -%}
{%- set is_input_group = is_input_group|default('input-group' in row_class) -%}
{#- Remove behavior class from the main container -#}
{%- set row_class = row_class|replace({'form-floating': '', 'input-group': ''}) -%}
<div{% with {attr: row_attr|merge({class: (row_class ~ ' row' ~ ((not compound or force_error|default(false)) and not valid ? ' is-invalid'))|trim})} %}{{ block('attributes') }}{% endwith %}>
{%- if is_form_floating or is_input_group -%}
<div class="{{ block('form_label_class') }}"></div>
<div class="{{ block('form_group_class') }}">
{%- if is_form_floating -%}
<div class="form-floating">
{{- form_widget(form, widget_attr) -}}
{{- form_label(form) -}}
</div>
{%- elseif is_input_group -%}
<div class="input-group">
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{#- Hack to properly display help with input group -#}
{{- form_help(form) -}}
</div>
{%- endif -%}
{%- if not is_input_group -%}
{{- form_help(form) -}}
{%- endif -%}
{{- form_errors(form) -}}
</div>
{%- else -%}
{{- form_label(form) -}}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
{%- endif -%}
{##}</div>
{%- endif -%}
{%- endblock form_row %}
{% block fieldset_form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<fieldset{% with {attr: row_attr|merge({class: row_attr.class|default('mb-3')|trim})} %}{{ block('attributes') }}{% endwith %}>
<div class="row{% if (not compound or force_error|default(false)) and not valid %} is-invalid{% endif %}">
{{- form_label(form) -}}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
</div>
</fieldset>
{%- endblock fieldset_form_row %}
{% block submit_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock submit_row %}
{% block reset_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock reset_row %}
{% block button_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
</div>{#--#}
</div>
{%- endblock button_row %}
{% block checkbox_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>{#--#}
</div>
{%- endblock checkbox_row %}
{% block form_group_class -%}
col-sm-10
{%- endblock form_group_class %}

View File

@@ -0,0 +1,374 @@
{% use "bootstrap_base_layout.html.twig" %}
{# Widgets #}
{% block money_widget -%}
{%- set prepend = not (money_pattern starts with '{{') -%}
{%- set append = not (money_pattern ends with '}}') -%}
{%- if prepend or append -%}
<div class="input-group {{ group_class|default('') }}">
{%- if prepend -%}
<span class="input-group-text">{{ money_pattern|form_encode_currency }}</span>
{%- endif -%}
{{- block('form_widget_simple') -}}
{%- if append -%}
<span class="input-group-text">{{ money_pattern|form_encode_currency }}</span>
{%- endif -%}
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock money_widget %}
{% block date_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{% if not valid %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
{% set valid = true %}
{% endif %}
{%- if datetime is not defined or not datetime -%}
<div {{ block('widget_container_attributes') -}}>
{%- endif %}
{%- if label is not same as(false) -%}
<div class="visually-hidden">
{{- form_label(form.year) -}}
{{- form_label(form.month) -}}
{{- form_label(form.day) -}}
</div>
{%- endif -%}
<div class="input-group">
{{- date_pattern|replace({
'{{ year }}': form_widget(form.year),
'{{ month }}': form_widget(form.month),
'{{ day }}': form_widget(form.day),
})|raw -}}
</div>
{%- if datetime is not defined or not datetime -%}
</div>
{%- endif -%}
{%- endif -%}
{%- endblock date_widget %}
{% block time_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{% if not valid %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
{% set valid = true %}
{% endif %}
{%- if datetime is not defined or false == datetime -%}
<div {{ block('widget_container_attributes') -}}>
{%- endif -%}
{%- if label is not same as(false) -%}
<div class="visually-hidden">
{{- form_label(form.hour) -}}
{%- if with_minutes -%}{{ form_label(form.minute) }}{%- endif -%}
{%- if with_seconds -%}{{ form_label(form.second) }}{%- endif -%}
</div>
{%- endif -%}
{% if with_minutes or with_seconds %}
<div class="input-group">
{% endif %}
{{- form_widget(form.hour) -}}
{%- if with_minutes -%}
<span class="input-group-text">:</span>
{{- form_widget(form.minute) -}}
{%- endif -%}
{%- if with_seconds -%}
<span class="input-group-text">:</span>
{{- form_widget(form.second) -}}
{%- endif -%}
{% if with_minutes or with_seconds %}
</div>
{% endif %}
{%- if datetime is not defined or false == datetime -%}
</div>
{%- endif -%}
{%- endif -%}
{%- endblock time_widget %}
{% block datetime_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{% if not valid %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
{% set valid = true %}
{% endif %}
<div {{ block('widget_container_attributes') }}>
{{- form_widget(form.date, { datetime: true } ) -}}
{{- form_errors(form.date) -}}
{{- form_widget(form.time, { datetime: true } ) -}}
{{- form_errors(form.time) -}}
</div>
{%- endif -%}
{%- endblock datetime_widget %}
{% block dateinterval_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{% if not valid %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%}
{% set valid = true %}
{% endif %}
<div {{ block('widget_container_attributes') }}>
{%- if with_years -%}
<div class="col-auto mb-3">
{{ form_label(form.years) }}
{{ form_widget(form.years) }}
</div>
{%- endif -%}
{%- if with_months -%}
<div class="col-auto mb-3">
{{ form_label(form.months) }}
{{ form_widget(form.months) }}
</div>
{%- endif -%}
{%- if with_weeks -%}
<div class="col-auto mb-3">
{{ form_label(form.weeks) }}
{{ form_widget(form.weeks) }}
</div>
{%- endif -%}
{%- if with_days -%}
<div class="col-auto mb-3">
{{ form_label(form.days) }}
{{ form_widget(form.days) }}
</div>
{%- endif -%}
{%- if with_hours -%}
<div class="col-auto mb-3">
{{ form_label(form.hours) }}
{{ form_widget(form.hours) }}
</div>
{%- endif -%}
{%- if with_minutes -%}
<div class="col-auto mb-3">
{{ form_label(form.minutes) }}
{{ form_widget(form.minutes) }}
</div>
{%- endif -%}
{%- if with_seconds -%}
<div class="col-auto mb-3">
{{ form_label(form.seconds) }}
{{ form_widget(form.seconds) }}
</div>
{%- endif -%}
{%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%}
</div>
{%- endif -%}
{%- endblock dateinterval_widget %}
{% block percent_widget -%}
{%- if symbol -%}
<div class="input-group">
{{- block('form_widget_simple') -}}
<span class="input-group-text">{{ symbol|default('%') }}</span>
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock percent_widget %}
{% block form_widget_simple -%}
{%- if type is not defined or type != 'hidden' %}
{%- set widget_class = ' form-control' %}
{%- if type|default('') == 'color' -%}
{%- set widget_class = widget_class ~ ' form-control-color' -%}
{%- elseif type|default('') == 'range' -%}
{%- set widget_class = ' form-range' -%}
{%- endif -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ widget_class)|trim}) -%}
{% endif -%}
{%- if type is defined and type in ['range', 'color'] %}
{# Attribute "required" is not supported #}
{% set required = false %}
{% endif -%}
{{- parent() -}}
{%- endblock form_widget_simple %}
{%- block widget_attributes -%}
{%- if not valid %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %}
{% endif -%}
{{ parent() }}
{%- endblock widget_attributes -%}
{%- block button_widget -%}
{%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%}
{{- parent() -}}
{%- endblock button_widget %}
{%- block submit_widget -%}
{%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%}
{{- parent() -}}
{%- endblock submit_widget %}
{%- block checkbox_widget -%}
{%- set attr_class = attr_class|default(attr.class|default('')) -%}
{%- set row_class = '' -%}
{%- if 'btn-check' not in attr_class -%}
{%- set attr_class = attr_class ~ ' form-check-input' -%}
{%- set row_class = 'form-check' -%}
{%- endif -%}
{%- set attr = attr|merge({class: attr_class|trim}) -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if 'checkbox-inline' in parent_label_class %}
{%- set row_class = row_class ~ ' form-check-inline' -%}
{% endif -%}
{%- if 'checkbox-switch' in parent_label_class %}
{%- set row_class = row_class ~ ' form-switch' -%}
{% endif -%}
{%- if row_class is not empty -%}
<div class="{{ row_class }}">
{%- endif -%}
{{- form_label(form, null, { widget: parent() }) -}}
{%- if row_class is not empty -%}
</div>
{%- endif -%}
{%- endblock checkbox_widget %}
{%- block radio_widget -%}
{%- set attr_class = attr_class|default(attr.class|default('')) -%}
{%- set row_class = '' -%}
{%- if 'btn-check' not in attr_class -%}
{%- set attr_class = attr_class ~ ' form-check-input' -%}
{%- set row_class = 'form-check' -%}
{%- endif -%}
{%- set attr = attr|merge({class: attr_class|trim}) -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if 'radio-inline' in parent_label_class -%}
{%- set row_class = row_class ~ ' form-check-inline' -%}
{%- endif -%}
{%- if row_class is not empty -%}
<div class="{{ row_class }}">
{%- endif -%}
{{- form_label(form, null, { widget: parent() }) -}}
{%- if row_class is not empty -%}
</div>
{%- endif -%}
{%- endblock radio_widget %}
{%- block choice_widget_collapsed -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%}
{{- parent() -}}
{%- endblock choice_widget_collapsed -%}
{%- block choice_widget_expanded -%}
<div {{ block('widget_container_attributes') }}>
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
valid: valid,
}) -}}
{% endfor -%}
</div>
{%- endblock choice_widget_expanded %}
{# Labels #}
{%- block form_label -%}
{% if label is not same as(false) -%}
{%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%}
{%- if compound is defined and compound -%}
{%- set element = 'legend' -%}
{%- if 'col-form-label' not in parent_label_class -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label' )|trim}) -%}
{%- endif -%}
{%- else -%}
{%- set row_class = row_class|default(row_attr.class|default('')) -%}
{%- set label_attr = label_attr|merge({for: id}) -%}
{%- if 'col-form-label' not in parent_label_class -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ('input-group' in row_class ? ' input-group-text' : ' form-label') )|trim}) -%}
{%- endif -%}
{%- endif -%}
{%- endif -%}
{{- parent() -}}
{%- endblock form_label %}
{%- block checkbox_radio_label -%}
{#- Do not display the label if widget is not defined in order to prevent double label rendering -#}
{%- if widget is defined -%}
{%- set label_attr_class = label_attr_class|default(label_attr.class|default('')) -%}
{%- if 'btn' not in label_attr_class -%}
{%- set label_attr_class = label_attr_class ~ ' form-check-label' -%}
{%- endif -%}
{%- set label_attr = label_attr|merge({class: label_attr_class|trim}) -%}
{%- if not compound -%}
{% set label_attr = label_attr|merge({'for': id}) %}
{%- endif -%}
{%- if required -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) -%}
{%- endif -%}
{%- if parent_label_class is defined -%}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ parent_label_class)|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) -%}
{%- endif -%}
{{ widget|raw }}
<label{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{%- if label is not same as(false) -%}
{{- block('form_label_content') -}}
{%- endif -%}
</label>
{%- endif -%}
{%- endblock checkbox_radio_label %}
{# Rows #}
{%- block form_row -%}
{%- if compound is defined and compound -%}
{%- set element = 'fieldset' -%}
{%- endif -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
{%- set row_class = row_class|default(row_attr.class|default('mb-3')|trim) -%}
<{{ element|default('div') }}{% with {attr: row_attr|merge({class: row_class})} %}{{ block('attributes') }}{% endwith %}>
{%- if 'form-floating' in row_class -%}
{{- form_widget(form, widget_attr) -}}
{{- form_label(form) -}}
{%- else -%}
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{%- endif -%}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</{{ element|default('div') }}>
{%- endblock form_row %}
{%- block button_row -%}
<div{% with {attr: row_attr|merge({class: row_attr.class|default('mb-3')|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
</div>
{%- endblock button_row %}
{# Errors #}
{%- block form_errors -%}
{%- if errors|length > 0 -%}
{%- for error in errors -%}
<div class="{% if form is not rootform %}invalid-feedback{% else %}alert alert-danger{% endif %} d-block">{{ error.message }}</div>
{%- endfor -%}
{%- endif %}
{%- endblock form_errors %}
{# Help #}
{%- block form_help -%}
{%- set row_class = row_attr.class|default('') -%}
{%- set help_class = ' form-text' -%}
{%- if 'input-group' in row_class -%}
{#- Hack to properly display help with input group -#}
{%- set help_class = ' input-group-text' -%}
{%- endif -%}
{%- if help is not empty -%}
{%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ help_class ~ ' mb-0')|trim}) -%}
{%- endif -%}
{{- parent() -}}
{%- endblock form_help %}

View File

@@ -0,0 +1,208 @@
{% use "form_div_layout.html.twig" %}
{# Widgets #}
{% block textarea_widget -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) %}
{{- parent() -}}
{%- endblock textarea_widget %}
{% block money_widget -%}
{% set prepend = not (money_pattern starts with '{{') %}
{% set append = not (money_pattern ends with '}}') %}
{% if prepend or append %}
<div class="input-group {{ group_class|default('') }}">
{% if prepend %}
<span class="input-group-addon">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
{{- block('form_widget_simple') -}}
{% if append %}
<span class="input-group-addon">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
</div>
{% else %}
{{- block('form_widget_simple') -}}
{% endif %}
{%- endblock money_widget %}
{% block percent_widget -%}
{%- if symbol -%}
<div class="input-group">
{{- block('form_widget_simple') -}}
<span class="input-group-addon">{{ symbol|default('%') }}</span>
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock percent_widget %}
{% block datetime_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' form-inline')|trim}) -%}
<div {{ block('widget_container_attributes') }}>
{{- form_errors(form.date) -}}
{{- form_errors(form.time) -}}
<div class="sr-only">
{%- if form.date.year is defined %}{{ form_label(form.date.year) }}{% endif -%}
{%- if form.date.month is defined %}{{ form_label(form.date.month) }}{% endif -%}
{%- if form.date.day is defined %}{{ form_label(form.date.day) }}{% endif -%}
{%- if form.time.hour is defined %}{{ form_label(form.time.hour) }}{% endif -%}
{%- if form.time.minute is defined %}{{ form_label(form.time.minute) }}{% endif -%}
{%- if form.time.second is defined %}{{ form_label(form.time.second) }}{% endif -%}
</div>
{{- form_widget(form.date, { datetime: true } ) -}}
{{- form_widget(form.time, { datetime: true } ) -}}
</div>
{%- endif -%}
{%- endblock datetime_widget %}
{% block date_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-inline')|trim}) -%}
{%- if datetime is not defined or not datetime -%}
<div {{ block('widget_container_attributes') -}}>
{%- endif %}
{%- if label is not same as(false) -%}
<div class="sr-only">
{{ form_label(form.year) }}
{{ form_label(form.month) }}
{{ form_label(form.day) }}
</div>
{%- endif -%}
{{- date_pattern|replace({
'{{ year }}': form_widget(form.year),
'{{ month }}': form_widget(form.month),
'{{ day }}': form_widget(form.day),
})|raw -}}
{%- if datetime is not defined or not datetime -%}
</div>
{%- endif -%}
{%- endif -%}
{%- endblock date_widget %}
{% block time_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-inline')|trim}) -%}
{%- if datetime is not defined or false == datetime -%}
<div {{ block('widget_container_attributes') -}}>
{%- endif -%}
{%- if label is not same as(false) -%}<div class="sr-only">{{ form_label(form.hour) }}</div>{%- endif -%}
{{- form_widget(form.hour) -}}
{%- if with_minutes -%}:{%- if label is not same as(false) -%}<div class="sr-only">{{ form_label(form.minute) }}</div>{%- endif -%}{{ form_widget(form.minute) }}{%- endif -%}
{%- if with_seconds -%}:{%- if label is not same as(false) -%}<div class="sr-only">{{ form_label(form.second) }}</div>{%- endif -%}{{ form_widget(form.second) }}{%- endif -%}
{%- if datetime is not defined or false == datetime -%}
</div>
{%- endif -%}
{%- endif -%}
{%- endblock time_widget %}
{%- block dateinterval_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-inline')|trim}) -%}
<div {{ block('widget_container_attributes') }}>
{{- form_errors(form) -}}
<div class="table-responsive">
<table class="table {{ table_class|default('table-bordered table-condensed table-striped') }}" role="presentation">
<thead>
<tr>
{%- if with_years %}<th>{{ form_label(form.years) }}</th>{% endif -%}
{%- if with_months %}<th>{{ form_label(form.months) }}</th>{% endif -%}
{%- if with_weeks %}<th>{{ form_label(form.weeks) }}</th>{% endif -%}
{%- if with_days %}<th>{{ form_label(form.days) }}</th>{% endif -%}
{%- if with_hours %}<th>{{ form_label(form.hours) }}</th>{% endif -%}
{%- if with_minutes %}<th>{{ form_label(form.minutes) }}</th>{% endif -%}
{%- if with_seconds %}<th>{{ form_label(form.seconds) }}</th>{% endif -%}
</tr>
</thead>
<tbody>
<tr>
{%- if with_years %}<td>{{ form_widget(form.years) }}</td>{% endif -%}
{%- if with_months %}<td>{{ form_widget(form.months) }}</td>{% endif -%}
{%- if with_weeks %}<td>{{ form_widget(form.weeks) }}</td>{% endif -%}
{%- if with_days %}<td>{{ form_widget(form.days) }}</td>{% endif -%}
{%- if with_hours %}<td>{{ form_widget(form.hours) }}</td>{% endif -%}
{%- if with_minutes %}<td>{{ form_widget(form.minutes) }}</td>{% endif -%}
{%- if with_seconds %}<td>{{ form_widget(form.seconds) }}</td>{% endif -%}
</tr>
</tbody>
</table>
</div>
{%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%}
</div>
{%- endif -%}
{%- endblock dateinterval_widget -%}
{% block choice_widget_expanded -%}
{%- if '-inline' in label_attr.class|default('') -%}
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
}) -}}
{% endfor -%}
{%- else -%}
<div {{ block('widget_container_attributes') }}>
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
}) -}}
{%- endfor -%}
</div>
{%- endif -%}
{%- endblock choice_widget_expanded %}
{# Labels #}
{% block choice_label -%}
{# remove the checkbox-inline and radio-inline class, it's only useful for embed labels #}
{%- set label_attr = label_attr|merge({class: label_attr.class|default('')|replace({'checkbox-inline': '', 'radio-inline': '', 'checkbox-custom': '', 'radio-custom': '', 'switch-custom': ''})|trim}) -%}
{{- block('form_label') -}}
{% endblock choice_label %}
{% block checkbox_label -%}
{{- block('checkbox_radio_label') -}}
{%- endblock checkbox_label %}
{% block radio_label -%}
{{- block('checkbox_radio_label') -}}
{%- endblock radio_label %}
{# Rows #}
{% block button_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
</div>
{%- endblock button_row %}
{% block choice_row -%}
{%- set force_error = true -%}
{{- block('form_row') -}}
{%- endblock choice_row %}
{% block date_row -%}
{%- set force_error = true -%}
{{- block('form_row') -}}
{%- endblock date_row %}
{% block time_row -%}
{%- set force_error = true -%}
{{- block('form_row') -}}
{%- endblock time_row %}
{% block datetime_row -%}
{%- set force_error = true -%}
{{- block('form_row') -}}
{%- endblock datetime_row %}

View File

@@ -0,0 +1,484 @@
{# Widgets #}
{%- block form_widget -%}
{% if compound %}
{{- block('form_widget_compound') -}}
{% else %}
{{- block('form_widget_simple') -}}
{% endif %}
{%- endblock form_widget -%}
{%- block form_widget_simple -%}
{%- set type = type|default('text') -%}
{%- if type == 'range' or type == 'color' -%}
{# Attribute "required" is not supported #}
{%- set required = false -%}
{%- endif -%}
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
{%- endblock form_widget_simple -%}
{%- block form_widget_compound -%}
<div {{ block('widget_container_attributes') }}>
{%- if form is rootform -%}
{{ form_errors(form) }}
{%- endif -%}
{{- block('form_rows') -}}
{{- form_rest(form) -}}
</div>
{%- endblock form_widget_compound -%}
{%- block collection_widget -%}
{% if prototype is defined and not prototype.rendered %}
{%- set attr = attr|merge({'data-prototype': form_row(prototype) }) -%}
{% endif %}
{{- block('form_widget') -}}
{%- endblock collection_widget -%}
{%- block textarea_widget -%}
<textarea {{ block('widget_attributes') }}>{{ value }}</textarea>
{%- endblock textarea_widget -%}
{%- block choice_widget -%}
{% if expanded %}
{{- block('choice_widget_expanded') -}}
{% else %}
{{- block('choice_widget_collapsed') -}}
{% endif %}
{%- endblock choice_widget -%}
{%- block choice_widget_expanded -%}
<div {{ block('widget_container_attributes') }}>
{%- for child in form %}
{{- form_widget(child) -}}
{{- form_label(child, null, {translation_domain: choice_translation_domain}) -}}
{% endfor -%}
</div>
{%- endblock choice_widget_expanded -%}
{%- block choice_widget_collapsed -%}
{%- if required and placeholder is none and not placeholder_in_choices and not multiple and (attr.size is not defined or attr.size <= 1) -%}
{% set required = false %}
{%- endif -%}
<select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple"{% endif %}>
{%- if placeholder is not none -%}
<option value=""{% if placeholder_attr|default({}) %}{% with { attr: placeholder_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}{% if required and value is empty %} selected="selected"{% endif %}>{{ placeholder != '' ? (translation_domain is same as(false) ? placeholder : placeholder|trans({}, translation_domain)) }}</option>
{%- endif -%}
{%- if preferred_choices|length > 0 -%}
{% set options = preferred_choices %}
{% set render_preferred_choices = true %}
{{- block('choice_widget_options') -}}
{%- if choices|length > 0 and separator is not none -%}
<option disabled="disabled">{{ separator }}</option>
{%- endif -%}
{%- endif -%}
{%- set options = choices -%}
{%- set render_preferred_choices = false -%}
{{- block('choice_widget_options') -}}
</select>
{%- endblock choice_widget_collapsed -%}
{%- block choice_widget_options -%}
{% for group_label, choice in options %}
{%- if choice is iterable -%}
<optgroup label="{{ choice_translation_domain is same as(false) ? group_label : group_label|trans({}, choice_translation_domain) }}">
{% set options = choice %}
{{- block('choice_widget_options') -}}
</optgroup>
{%- else -%}
<option value="{{ choice.value }}"{% if choice.attr %}{% with { attr: choice.attr } %}{{ block('attributes') }}{% endwith %}{% endif %}{% if not render_preferred_choices|default(false) and choice is selectedchoice(value) %} selected="selected"{% endif %}>{{ choice_translation_domain is same as(false) ? choice.label : choice.label|trans(choice.labelTranslationParameters, choice_translation_domain) }}</option>
{%- endif -%}
{% endfor %}
{%- endblock choice_widget_options -%}
{%- block checkbox_widget -%}
<input type="checkbox" {{ block('widget_attributes') }}{% if value is defined %} value="{{ value }}"{% endif %}{% if checked %} checked="checked"{% endif %} />
{%- endblock checkbox_widget -%}
{%- block radio_widget -%}
<input type="radio" {{ block('widget_attributes') }}{% if value is defined %} value="{{ value }}"{% endif %}{% if checked %} checked="checked"{% endif %} />
{%- endblock radio_widget -%}
{%- block datetime_widget -%}
{% if widget == 'single_text' %}
{{- block('form_widget_simple') -}}
{%- else -%}
<div {{ block('widget_container_attributes') }}>
{{- form_errors(form.date) -}}
{{- form_errors(form.time) -}}
{{- form_widget(form.date) -}}
{{- form_widget(form.time) -}}
</div>
{%- endif -%}
{%- endblock datetime_widget -%}
{%- block date_widget -%}
{%- if widget == 'single_text' -%}
{{ block('form_widget_simple') }}
{%- else -%}
<div {{ block('widget_container_attributes') }}>
{{- date_pattern|replace({
'{{ year }}': form_widget(form.year),
'{{ month }}': form_widget(form.month),
'{{ day }}': form_widget(form.day),
})|raw -}}
</div>
{%- endif -%}
{%- endblock date_widget -%}
{%- block time_widget -%}
{%- if widget == 'single_text' -%}
{{ block('form_widget_simple') }}
{%- else -%}
{%- set vars = widget == 'text' ? { 'attr': { 'size': 1 }} : {} -%}
<div {{ block('widget_container_attributes') }}>
{{ form_widget(form.hour, vars) }}{% if with_minutes %}:{{ form_widget(form.minute, vars) }}{% endif %}{% if with_seconds %}:{{ form_widget(form.second, vars) }}{% endif %}
</div>
{%- endif -%}
{%- endblock time_widget -%}
{%- block dateinterval_widget -%}
{%- if widget == 'single_text' -%}
{{- block('form_widget_simple') -}}
{%- else -%}
<div {{ block('widget_container_attributes') }}>
{{- form_errors(form) -}}
<table class="{{ table_class|default('') }}" role="presentation">
<thead>
<tr>
{%- if with_years %}<th>{{ form_label(form.years) }}</th>{% endif -%}
{%- if with_months %}<th>{{ form_label(form.months) }}</th>{% endif -%}
{%- if with_weeks %}<th>{{ form_label(form.weeks) }}</th>{% endif -%}
{%- if with_days %}<th>{{ form_label(form.days) }}</th>{% endif -%}
{%- if with_hours %}<th>{{ form_label(form.hours) }}</th>{% endif -%}
{%- if with_minutes %}<th>{{ form_label(form.minutes) }}</th>{% endif -%}
{%- if with_seconds %}<th>{{ form_label(form.seconds) }}</th>{% endif -%}
</tr>
</thead>
<tbody>
<tr>
{%- if with_years %}<td>{{ form_widget(form.years) }}</td>{% endif -%}
{%- if with_months %}<td>{{ form_widget(form.months) }}</td>{% endif -%}
{%- if with_weeks %}<td>{{ form_widget(form.weeks) }}</td>{% endif -%}
{%- if with_days %}<td>{{ form_widget(form.days) }}</td>{% endif -%}
{%- if with_hours %}<td>{{ form_widget(form.hours) }}</td>{% endif -%}
{%- if with_minutes %}<td>{{ form_widget(form.minutes) }}</td>{% endif -%}
{%- if with_seconds %}<td>{{ form_widget(form.seconds) }}</td>{% endif -%}
</tr>
</tbody>
</table>
{%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%}
</div>
{%- endif -%}
{%- endblock dateinterval_widget -%}
{%- block number_widget -%}
{# type="number" doesn't work with floats in localized formats #}
{%- set type = type|default('text') -%}
{{ block('form_widget_simple') }}
{%- endblock number_widget -%}
{%- block integer_widget -%}
{%- set type = type|default('number') -%}
{{ block('form_widget_simple') }}
{%- endblock integer_widget -%}
{%- block money_widget -%}
{{ money_pattern|form_encode_currency(block('form_widget_simple')) }}
{%- endblock money_widget -%}
{%- block url_widget -%}
{%- set type = type|default('url') -%}
{{ block('form_widget_simple') }}
{%- endblock url_widget -%}
{%- block search_widget -%}
{%- set type = type|default('search') -%}
{{ block('form_widget_simple') }}
{%- endblock search_widget -%}
{%- block percent_widget -%}
{%- set type = type|default('text') -%}
{{ block('form_widget_simple') }}{% if symbol %} {{ symbol|default('%') }}{% endif %}
{%- endblock percent_widget -%}
{%- block password_widget -%}
{%- set type = type|default('password') -%}
{{ block('form_widget_simple') }}
{%- endblock password_widget -%}
{%- block hidden_widget -%}
{%- set type = type|default('hidden') -%}
{{ block('form_widget_simple') }}
{%- endblock hidden_widget -%}
{%- block email_widget -%}
{%- set type = type|default('email') -%}
{{ block('form_widget_simple') }}
{%- endblock email_widget -%}
{%- block range_widget -%}
{% set type = type|default('range') %}
{{- block('form_widget_simple') -}}
{%- endblock range_widget %}
{%- block button_widget -%}
{%- if label is empty -%}
{%- if label_format is not empty -%}
{% set label = label_format|replace({
'%name%': name,
'%id%': id,
}) %}
{%- elseif label is not same as(false) -%}
{% set label = name|humanize %}
{%- endif -%}
{%- endif -%}
<button type="{{ type|default('button') }}" {{ block('button_attributes') }}>
{%- if translation_domain is same as(false) -%}
{%- if label_html is same as(false) -%}
{{- label -}}
{%- else -%}
{{- label|raw -}}
{%- endif -%}
{%- else -%}
{%- if label_html is same as(false) -%}
{{- label|trans(label_translation_parameters, translation_domain) -}}
{%- else -%}
{{- label|trans(label_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
</button>
{%- endblock button_widget -%}
{%- block submit_widget -%}
{%- set type = type|default('submit') -%}
{{ block('button_widget') }}
{%- endblock submit_widget -%}
{%- block reset_widget -%}
{%- set type = type|default('reset') -%}
{{ block('button_widget') }}
{%- endblock reset_widget -%}
{%- block tel_widget -%}
{%- set type = type|default('tel') -%}
{{ block('form_widget_simple') }}
{%- endblock tel_widget -%}
{%- block color_widget -%}
{%- set type = type|default('color') -%}
{{ block('form_widget_simple') }}
{%- endblock color_widget -%}
{%- block week_widget -%}
{%- if widget == 'single_text' -%}
{{ block('form_widget_simple') }}
{%- else -%}
{%- set vars = widget == 'text' ? { 'attr': { 'size': 1 }} : {} -%}
<div {{ block('widget_container_attributes') }}>
{{ form_widget(form.year, vars) }}-{{ form_widget(form.week, vars) }}
</div>
{%- endif -%}
{%- endblock week_widget -%}
{# Labels #}
{%- block form_label -%}
{% if label is not same as(false) -%}
{% if not compound -%}
{% set label_attr = label_attr|merge({'for': id}) %}
{%- endif -%}
{% if required -%}
{% set label_attr = label_attr|merge({'class': (label_attr.class|default('') ~ ' required')|trim}) %}
{%- endif -%}
<{{ element|default('label') }}{% if label_attr %}{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}>
{{- block('form_label_content') -}}
</{{ element|default('label') }}>
{%- endif -%}
{%- endblock form_label -%}
{%- block form_label_content -%}
{%- if label is empty -%}
{%- if label_format is not empty -%}
{% set label = label_format|replace({
'%name%': name,
'%id%': id,
}) %}
{%- else -%}
{% set label = name|humanize %}
{%- endif -%}
{%- endif -%}
{%- if translation_domain is same as(false) -%}
{%- if label_html is same as(false) -%}
{{- label -}}
{%- else -%}
{{- label|raw -}}
{%- endif -%}
{%- else -%}
{%- if label_html is same as(false) -%}
{{- label|trans(label_translation_parameters, translation_domain) -}}
{%- else -%}
{{- label|trans(label_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
{%- endblock form_label_content -%}
{%- block button_label -%}{%- endblock -%}
{# Help #}
{% block form_help -%}
{%- if help is not empty -%}
{%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ ' help-text')|trim}) -%}
<{{ element|default('div') }} id="{{ id }}_help"{% with { attr: help_attr } %}{{ block('attributes') }}{% endwith %}>
{{- block('form_help_content') -}}
</{{ element|default('div') }}>
{%- endif -%}
{%- endblock form_help %}
{% block form_help_content -%}
{%- if translation_domain is same as(false) -%}
{%- if help_html is same as(false) -%}
{{- help -}}
{%- else -%}
{{- help|raw -}}
{%- endif -%}
{%- else -%}
{%- if help_html is same as(false) -%}
{{- help|trans(help_translation_parameters, translation_domain) -}}
{%- else -%}
{{- help|trans(help_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
{%- endblock form_help_content %}
{# Rows #}
{%- block repeated_row -%}
{#
No need to render the errors here, as all errors are mapped
to the first child (see RepeatedTypeValidatorExtension).
#}
{{- block('form_rows') -}}
{%- endblock repeated_row -%}
{%- block form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
{{- form_errors(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
</div>
{%- endblock form_row -%}
{%- block button_row -%}
<div{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
{{- form_widget(form) -}}
</div>
{%- endblock button_row -%}
{%- block hidden_row -%}
{{ form_widget(form) }}
{%- endblock hidden_row -%}
{# Misc #}
{%- block form -%}
{{ form_start(form) }}
{{- form_widget(form) -}}
{{ form_end(form) }}
{%- endblock form -%}
{%- block form_start -%}
{%- do form.setMethodRendered() -%}
{% set method = method|upper %}
{%- if method in ["GET", "POST"] -%}
{% set form_method = method %}
{%- else -%}
{% set form_method = "POST" %}
{%- endif -%}
<form{% if name != '' %} name="{{ name }}"{% endif %} method="{{ form_method|lower }}"{% if action != '' %} action="{{ action }}"{% endif %}{{ block('attributes') }}{% if multipart %} enctype="multipart/form-data"{% endif %}>
{%- if form_method != method -%}
<input type="hidden" name="_method" value="{{ method }}" />
{%- endif -%}
{%- endblock form_start -%}
{%- block form_end -%}
{%- if not render_rest is defined or render_rest -%}
{{ form_rest(form) }}
{%- endif -%}
</form>
{%- endblock form_end -%}
{%- block form_errors -%}
{%- if errors|length > 0 -%}
<ul>
{%- for error in errors -%}
<li>{{ error.message }}</li>
{%- endfor -%}
</ul>
{%- endif -%}
{%- endblock form_errors -%}
{%- block form_rest -%}
{% for child in form -%}
{% if not child.rendered %}
{{- form_row(child) -}}
{% endif %}
{%- endfor -%}
{% if not form.methodRendered and form is rootform %}
{%- do form.setMethodRendered() -%}
{% set method = method|upper %}
{%- if method in ["GET", "POST"] -%}
{% set form_method = method %}
{%- else -%}
{% set form_method = "POST" %}
{%- endif -%}
{%- if form_method != method -%}
<input type="hidden" name="_method" value="{{ method }}" />
{%- endif -%}
{% endif -%}
{% endblock form_rest %}
{# Support #}
{%- block form_rows -%}
{% for child in form|filter(child => not child.rendered) %}
{{- form_row(child) -}}
{% endfor %}
{%- endblock form_rows -%}
{%- block widget_attributes -%}
id="{{ id }}" name="{{ full_name }}"
{%- if disabled %} disabled="disabled"{% endif -%}
{%- if required %} required="required"{% endif -%}
{{ block('attributes') }}
{%- endblock widget_attributes -%}
{%- block widget_container_attributes -%}
{%- if id is not empty %}id="{{ id }}"{% endif -%}
{{ block('attributes') }}
{%- endblock widget_container_attributes -%}
{%- block button_attributes -%}
id="{{ id }}" name="{{ full_name }}"{% if disabled %} disabled="disabled"{% endif -%}
{{ block('attributes') }}
{%- endblock button_attributes -%}
{% block attributes -%}
{%- for attrname, attrvalue in attr -%}
{{- " " -}}
{%- if attrname in ['placeholder', 'title'] -%}
{{- attrname }}="{{ translation_domain is same as(false) or attrvalue is null ? attrvalue : attrvalue|trans(attr_translation_parameters, translation_domain) }}"
{%- elseif attrvalue is same as(true) -%}
{{- attrname }}="{{ attrname }}"
{%- elseif attrvalue is not same as(false) -%}
{{- attrname }}="{{ attrvalue }}"
{%- endif -%}
{%- endfor -%}
{%- endblock attributes -%}

View File

@@ -0,0 +1,50 @@
{% use "form_div_layout.html.twig" %}
{%- block form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<tr{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
<td>
{{- form_label(form) -}}
</td>
<td>
{{- form_errors(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
</td>
</tr>
{%- endblock form_row -%}
{%- block button_row -%}
<tr{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
<td></td>
<td>
{{- form_widget(form) -}}
</td>
</tr>
{%- endblock button_row -%}
{%- block hidden_row -%}
{%- set style = row_attr.style is defined ? (row_attr.style ~ (row_attr.style|trim|last != ';' ? '; ')) -%}
<tr{% with {attr: row_attr|merge({style: (style ~ ' display: none')|trim})} %}{{ block('attributes') }}{% endwith %}>
<td colspan="2">
{{- form_widget(form) -}}
</td>
</tr>
{%- endblock hidden_row -%}
{%- block form_widget_compound -%}
<table {{ block('widget_container_attributes') }}>
{%- if form is rootform and errors|length > 0 -%}
<tr>
<td colspan="2">
{{- form_errors(form) -}}
</td>
</tr>
{%- endif -%}
{{- block('form_rows') -}}
{{- form_rest(form) -}}
</table>
{%- endblock form_widget_compound -%}

View File

@@ -0,0 +1,346 @@
{% extends "form_div_layout.html.twig" %}
{# Based on Foundation 5 Doc #}
{# Widgets #}
{% block form_widget_simple -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block textarea_widget -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock textarea_widget %}
{% block button_widget -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' button')|trim}) %}
{{- parent() -}}
{%- endblock button_widget %}
{% block money_widget -%}
<div class="row collapse">
{% set prepend = '{{' == money_pattern[0:2] %}
{% if not prepend %}
<div class="small-3 large-2 columns">
<span class="prefix">{{ money_pattern|form_encode_currency }}</span>
</div>
{% endif %}
<div class="small-9 large-10 columns">
{{- block('form_widget_simple') -}}
</div>
{% if prepend %}
<div class="small-3 large-2 columns">
<span class="postfix">{{ money_pattern|form_encode_currency }}</span>
</div>
{% endif %}
</div>
{%- endblock money_widget %}
{% block percent_widget -%}
<div class="row collapse">
{%- if symbol -%}
<div class="small-9 large-10 columns">
{{- block('form_widget_simple') -}}
</div>
<div class="small-3 large-2 columns">
<span class="postfix">{{ symbol|default('%') }}</span>
</div>
{%- else -%}
<div class="small-12 large-12 columns">
{{- block('form_widget_simple') -}}
</div>
{%- endif -%}
</div>
{%- endblock percent_widget %}
{% block datetime_widget -%}
{% if widget == 'single_text' %}
{{- block('form_widget_simple') -}}
{% else %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' row')|trim}) %}
<div class="row">
<div class="large-7 columns">{{ form_errors(form.date) }}</div>
<div class="large-5 columns">{{ form_errors(form.time) }}</div>
</div>
<div {{ block('widget_container_attributes') }}>
<div class="large-7 columns">{{ form_widget(form.date, { datetime: true } ) }}</div>
<div class="large-5 columns">{{ form_widget(form.time, { datetime: true } ) }}</div>
</div>
{% endif %}
{%- endblock datetime_widget %}
{% block date_widget -%}
{% if widget == 'single_text' %}
{{- block('form_widget_simple') -}}
{% else %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' row')|trim}) %}
{% if datetime is not defined or not datetime %}
<div {{ block('widget_container_attributes') }}>
{% endif %}
{{- date_pattern|replace({
'{{ year }}': '<div class="large-4 columns">' ~ form_widget(form.year) ~ '</div>',
'{{ month }}': '<div class="large-4 columns">' ~ form_widget(form.month) ~ '</div>',
'{{ day }}': '<div class="large-4 columns">' ~ form_widget(form.day) ~ '</div>',
})|raw -}}
{% if datetime is not defined or not datetime %}
</div>
{% endif %}
{% endif %}
{%- endblock date_widget %}
{% block time_widget -%}
{% if widget == 'single_text' %}
{{- block('form_widget_simple') -}}
{% else %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' row')|trim}) %}
{% if datetime is not defined or false == datetime %}
<div {{ block('widget_container_attributes') -}}>
{% endif %}
{% if with_seconds %}
<div class="large-4 columns">{{ form_widget(form.hour) }}</div>
<div class="large-4 columns">
<div class="row collapse">
<div class="small-3 large-2 columns">
<span class="prefix">:</span>
</div>
<div class="small-9 large-10 columns">
{{ form_widget(form.minute) }}
</div>
</div>
</div>
<div class="large-4 columns">
<div class="row collapse">
<div class="small-3 large-2 columns">
<span class="prefix">:</span>
</div>
<div class="small-9 large-10 columns">
{{ form_widget(form.second) }}
</div>
</div>
</div>
{% else %}
<div class="large-6 columns">{{ form_widget(form.hour) }}</div>
<div class="large-6 columns">
<div class="row collapse">
<div class="small-3 large-2 columns">
<span class="prefix">:</span>
</div>
<div class="small-9 large-10 columns">
{{ form_widget(form.minute) }}
</div>
</div>
</div>
{% endif %}
{% if datetime is not defined or false == datetime %}
</div>
{% endif %}
{% endif %}
{%- endblock time_widget %}
{% block choice_widget_collapsed -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{% if multiple -%}
{% set attr = attr|merge({style: (attr.style|default('') ~ ' height: auto; background-image: none;')|trim}) %}
{% endif %}
{% if required and placeholder is none and not placeholder_in_choices and not multiple -%}
{% set required = false %}
{%- endif -%}
<select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple" data-customforms="disabled"{% endif %}>
{% if placeholder is not none -%}
<option value=""{% if placeholder_attr|default({}) %}{% with { attr: placeholder_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}{% if required and value is empty %} selected="selected"{% endif %}>{{ translation_domain is same as(false) ? placeholder : placeholder|trans({}, translation_domain) }}</option>
{%- endif %}
{%- if preferred_choices|length > 0 -%}
{% set options = preferred_choices %}
{% set render_preferred_choices = true %}
{{- block('choice_widget_options') -}}
{% if choices|length > 0 and separator is not none -%}
<option disabled="disabled">{{ separator }}</option>
{%- endif %}
{%- endif -%}
{% set options = choices -%}
{%- set render_preferred_choices = false -%}
{{- block('choice_widget_options') -}}
</select>
{%- endblock choice_widget_collapsed %}
{% block choice_widget_expanded -%}
{% if '-inline' in label_attr.class|default('') %}
<ul class="inline-list">
{% for child in form %}
<li>{{ form_widget(child, {
parent_label_class: label_attr.class|default(''),
}) }}</li>
{% endfor %}
</ul>
{% else %}
<div {{ block('widget_container_attributes') }}>
{% for child in form %}
{{ form_widget(child, {
parent_label_class: label_attr.class|default(''),
}) }}
{% endfor %}
</div>
{% endif %}
{%- endblock choice_widget_expanded %}
{% block checkbox_widget -%}
{% set parent_label_class = parent_label_class|default('') %}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{% if 'checkbox-inline' in parent_label_class %}
{{ form_label(form, null, { widget: parent() }) }}
{% else %}
<div class="checkbox">
{{ form_label(form, null, { widget: parent() }) }}
</div>
{% endif %}
{%- endblock checkbox_widget %}
{% block radio_widget -%}
{% set parent_label_class = parent_label_class|default('') %}
{% if 'radio-inline' in parent_label_class %}
{{ form_label(form, null, { widget: parent() }) }}
{% else %}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
<div class="radio">
{{ form_label(form, null, { widget: parent() }) }}
</div>
{% endif %}
{%- endblock radio_widget %}
{# Labels #}
{% block form_label -%}
{% if errors|length > 0 -%}
{% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock form_label %}
{% block choice_label -%}
{% if errors|length > 0 -%}
{% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{# remove the checkbox-inline and radio-inline class, it's only useful for embed labels #}
{% set label_attr = label_attr|merge({class: label_attr.class|default('')|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) %}
{{- block('form_label') -}}
{%- endblock choice_label %}
{% block checkbox_label -%}
{{- block('checkbox_radio_label') -}}
{%- endblock checkbox_label %}
{% block radio_label -%}
{{- block('checkbox_radio_label') -}}
{%- endblock radio_label %}
{% block checkbox_radio_label -%}
{% if required %}
{% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) %}
{% endif %}
{% if errors|length > 0 -%}
{% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{%- if parent_label_class is defined -%}
{% set embed_label_classes = parent_label_class|split(' ')|filter(class => class in ['checkbox-inline', 'radio-inline']) %}
{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ embed_label_classes|join(' '))|trim}) -%}
{% endif %}
{% if label is empty %}
{%- if label_format is not empty -%}
{% set label = label_format|replace({
'%name%': name,
'%id%': id,
}) %}
{%- else -%}
{% set label = name|humanize %}
{%- endif -%}
{% endif %}
<label{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}>
{{ widget|raw }}
{%- if label is not same as(false) -%}
{{- block('form_label_content') -}}
{%- endif -%}
</label>
{%- endblock checkbox_radio_label %}
{# Rows #}
{% block form_row -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>
<div class="large-12 columns{% if (not compound or force_error|default(false)) and not valid %} error{% endif %}">
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>
</div>
{%- endblock form_row %}
{% block choice_row -%}
{% set force_error = true %}
{{ block('form_row') }}
{%- endblock choice_row %}
{% block date_row -%}
{% set force_error = true %}
{{ block('form_row') }}
{%- endblock date_row %}
{% block time_row -%}
{% set force_error = true %}
{{ block('form_row') }}
{%- endblock time_row %}
{% block datetime_row -%}
{% set force_error = true %}
{{ block('form_row') }}
{%- endblock datetime_row %}
{% block checkbox_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>
<div class="large-12 columns{% if not valid %} error{% endif %}">
{{ form_widget(form) }}
{{- form_help(form) -}}
{{ form_errors(form) }}
</div>
</div>
{%- endblock checkbox_row %}
{% block radio_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>
<div class="large-12 columns{% if not valid %} error{% endif %}">
{{ form_widget(form) }}
{{- form_help(form) -}}
{{ form_errors(form) }}
</div>
</div>
{%- endblock radio_row %}
{# Errors #}
{% block form_errors -%}
{% if errors|length > 0 -%}
{% if form is not rootform %}<small class="error">{% else %}<div data-alert class="alert-box alert">{% endif %}
{%- for error in errors -%}
{{ error.message }}
{% if not loop.last %}, {% endif %}
{%- endfor -%}
{% if form is not rootform %}</small>{% else %}</div>{% endif %}
{%- endif %}
{%- endblock form_errors %}

View File

@@ -0,0 +1,50 @@
{% extends "form_div_layout.html.twig" %}
{%- block checkbox_row -%}
{%- set parent_class = parent_class|default(attr.class|default('')) -%}
{%- if 'switch-input' in parent_class -%}
{{- form_label(form) -}}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' switch-input')|trim}) -%}
{{- form_widget(form) -}}
<label class="switch-paddle" for="{{ form.vars.id }}"></label>
{{- form_errors(form) -}}
{%- else -%}
{{- block('form_row') -}}
{%- endif -%}
{%- endblock checkbox_row -%}
{% block money_widget -%}
{% set prepend = not (money_pattern starts with '{{') %}
{% set append = not (money_pattern ends with '}}') %}
{% if prepend or append %}
<div class="input-group">
{% if prepend %}
<span class="input-group-label">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' input-group-field')|trim}) %}
{{- block('form_widget_simple') -}}
{% if append %}
<span class="input-group-label">{{ money_pattern|form_encode_currency }}</span>
{% endif %}
</div>
{% else %}
{{- block('form_widget_simple') -}}
{% endif %}
{%- endblock money_widget %}
{% block percent_widget -%}
{%- if symbol -%}
<div class="input-group">
{% set attr = attr|merge({class: (attr.class|default('') ~ ' input-group-field')|trim}) %}
{{- block('form_widget_simple') -}}
<span class="input-group-label">{{ symbol|default('%') }}</span>
</div>
{%- else -%}
{{- block('form_widget_simple') -}}
{%- endif -%}
{%- endblock percent_widget %}
{% block button_widget -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' button')|trim}) %}
{{- parent() -}}
{%- endblock button_widget %}

View File

@@ -0,0 +1,69 @@
{% use 'form_div_layout.html.twig' %}
{%- block form_row -%}
{%- set row_attr = row_attr|merge({ class: row_attr.class|default(row_class|default('mb-6')) }) -%}
{{- parent() -}}
{%- endblock form_row -%}
{%- block widget_attributes -%}
{%- set attr = attr|merge({ class: attr.class|default(widget_class|default('mt-1 w-full')) ~ (disabled ? ' ' ~ widget_disabled_class|default('border-gray-300 text-gray-500')) ~ (errors|length ? ' ' ~ widget_errors_class|default('border-red-700')) }) -%}
{{- parent() -}}
{%- endblock widget_attributes -%}
{%- block form_label -%}
{%- set label_attr = label_attr|merge({ class: label_attr.class|default(label_class|default('block text-gray-800')) }) -%}
{{- parent() -}}
{%- endblock form_label -%}
{%- block form_help -%}
{%- set help_attr = help_attr|merge({ class: help_attr.class|default(help_class|default('mt-1 text-gray-600')) }) -%}
{{- parent() -}}
{%- endblock form_help -%}
{%- block form_errors -%}
{%- if errors|length > 0 -%}
<ul>
{%- for error in errors -%}
<li class="{{ error_item_class|default('text-red-700') }}">{{ error.message }}</li>
{%- endfor -%}
</ul>
{%- endif -%}
{%- endblock form_errors -%}
{%- block choice_widget_expanded -%}
{%- set attr = attr|merge({ class: attr.class|default('mt-2') }) -%}
<div {{ block('widget_container_attributes') }}>
{%- for child in form %}
<div class="flex items-center">
{{- form_widget(child) -}}
{{- form_label(child, null, { translation_domain: choice_translation_domain }) -}}
</div>
{% endfor -%}
</div>
{%- endblock choice_widget_expanded -%}
{%- block checkbox_row -%}
{%- set row_attr = row_attr|merge({ class: row_attr.class|default(row_class|default('mb-6')) }) -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
<div{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
{{- form_errors(form) -}}
<div class="inline-flex items-center">
{{- form_widget(form, widget_attr) -}}
{{- form_label(form) -}}
</div>
{{- form_help(form) -}}
</div>
{%- endblock checkbox_row -%}
{%- block checkbox_widget -%}
{%- set widget_class = widget_class|default('mr-2') -%}
{{- parent() -}}
{%- endblock checkbox_widget -%}
{%- block radio_widget -%}
{%- set widget_class = widget_class|default('mr-2') -%}
{{- parent() -}}
{%- endblock radio_widget -%}

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\Bridge\Twig\Test;
use Symfony\Bridge\Twig\Form\TwigRendererEngine;
use Symfony\Bridge\Twig\Test\Traits\RuntimeLoaderProvider;
use Symfony\Component\Form\FormRenderer;
use Symfony\Component\Form\FormRendererInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Test\FormIntegrationTestCase;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @author Romain Monteil <monteil.romain@gmail.com>
*/
abstract class FormLayoutTestCase extends FormIntegrationTestCase
{
use RuntimeLoaderProvider;
protected FormRendererInterface $renderer;
protected function setUp(): void
{
parent::setUp();
$loader = new FilesystemLoader($this->getTemplatePaths());
$environment = new Environment($loader, ['strict_variables' => true]);
$environment->setExtensions($this->getTwigExtensions());
foreach ($this->getTwigGlobals() as $name => $value) {
$environment->addGlobal($name, $value);
}
$rendererEngine = new TwigRendererEngine($this->getThemes(), $environment);
$this->renderer = new FormRenderer($rendererEngine, $this->createMock(CsrfTokenManagerInterface::class));
$this->registerTwigRuntimeLoader($environment, $this->renderer);
}
protected function assertMatchesXpath($html, $expression, $count = 1): void
{
$dom = new \DOMDocument('UTF-8');
try {
// Wrap in <root> node so we can load HTML with multiple tags at
// the top level
$dom->loadXML('<root>'.$html.'</root>');
} catch (\Exception $e) {
$this->fail(sprintf(
"Failed loading HTML:\n\n%s\n\nError: %s",
$html,
$e->getMessage()
));
}
$xpath = new \DOMXPath($dom);
$nodeList = $xpath->evaluate('/root'.$expression);
if ($nodeList->length != $count) {
$dom->formatOutput = true;
$this->fail(sprintf(
"Failed asserting that \n\n%s\n\nmatches exactly %s. Matches %s in \n\n%s",
$expression,
1 == $count ? 'once' : $count.' times',
1 == $nodeList->length ? 'once' : $nodeList->length.' times',
// strip away <root> and </root>
substr($dom->saveHTML(), 6, -8)
));
} else {
$this->addToAssertionCount(1);
}
}
abstract protected function getTemplatePaths(): array;
abstract protected function getTwigExtensions(): array;
protected function getTwigGlobals(): array
{
return [];
}
abstract protected function getThemes(): array;
protected function renderForm(FormView $view, array $vars = []): string
{
return $this->renderer->renderBlock($view, 'form', $vars);
}
protected function renderLabel(FormView $view, $label = null, array $vars = []): string
{
if (null !== $label) {
$vars += ['label' => $label];
}
return $this->renderer->searchAndRenderBlock($view, 'label', $vars);
}
protected function renderHelp(FormView $view): string
{
return $this->renderer->searchAndRenderBlock($view, 'help');
}
protected function renderErrors(FormView $view): string
{
return $this->renderer->searchAndRenderBlock($view, 'errors');
}
protected function renderWidget(FormView $view, array $vars = []): string
{
return $this->renderer->searchAndRenderBlock($view, 'widget', $vars);
}
protected function renderRow(FormView $view, array $vars = []): string
{
return $this->renderer->searchAndRenderBlock($view, 'row', $vars);
}
protected function renderRest(FormView $view, array $vars = []): string
{
return $this->renderer->searchAndRenderBlock($view, 'rest', $vars);
}
protected function renderStart(FormView $view, array $vars = []): string
{
return $this->renderer->renderBlock($view, 'form_start', $vars);
}
protected function renderEnd(FormView $view, array $vars = []): string
{
return $this->renderer->renderBlock($view, 'form_end', $vars);
}
protected function setTheme(FormView $view, array $themes, $useDefaultThemes = true): void
{
$this->renderer->setTheme($view, $themes, $useDefaultThemes);
}
}

View File

@@ -0,0 +1,28 @@
<?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\Bridge\Twig\Test\Traits;
use Symfony\Component\Form\FormRenderer;
use Twig\Environment;
use Twig\RuntimeLoader\RuntimeLoaderInterface;
trait RuntimeLoaderProvider
{
protected function registerTwigRuntimeLoader(Environment $environment, FormRenderer $renderer)
{
$loader = $this->createMock(RuntimeLoaderInterface::class);
$loader->expects($this->any())->method('load')->willReturnMap([
['Symfony\Component\Form\FormRenderer', $renderer],
]);
$environment->addRuntimeLoader($loader);
}
}

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\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\DumpNode;
use Twig\Node\Expression\Variable\LocalVariable;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Token Parser for the 'dump' tag.
*
* Dump variables with:
*
* {% dump %}
* {% dump foo %}
* {% dump foo, bar %}
*
* @author Julien Galenski <julien.galenski@gmail.com>
*/
final class DumpTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$values = null;
if (!$this->parser->getStream()->test(Token::BLOCK_END_TYPE)) {
$values = $this->parser->getExpressionParser()->parseMultitargetExpression();
}
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
return new DumpNode(class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : $this->parser->getVarName(), $values, $token->getLine(), $this->getTag());
}
public function getTag(): string
{
return 'dump';
}
}

View File

@@ -0,0 +1,58 @@
<?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\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\FormThemeNode;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Token Parser for the 'form_theme' tag.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class FormThemeTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$form = $this->parser->getExpressionParser()->parseExpression();
$only = false;
if ($this->parser->getStream()->test(Token::NAME_TYPE, 'with')) {
$this->parser->getStream()->next();
$resources = $this->parser->getExpressionParser()->parseExpression();
if ($this->parser->getStream()->nextIf(Token::NAME_TYPE, 'only')) {
$only = true;
}
} else {
$resources = new ArrayExpression([], $stream->getCurrent()->getLine());
do {
$resources->addElement($this->parser->getExpressionParser()->parseExpression());
} while (!$stream->test(Token::BLOCK_END_TYPE));
}
$stream->expect(Token::BLOCK_END_TYPE);
return new FormThemeNode($form, $resources, $lineno, $this->getTag(), $only);
}
public function getTag(): string
{
return 'form_theme';
}
}

View File

@@ -0,0 +1,65 @@
<?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\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\StopwatchNode;
use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\Variable\LocalVariable;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Token Parser for the stopwatch tag.
*
* @author Wouter J <wouter@wouterj.nl>
*/
final class StopwatchTokenParser extends AbstractTokenParser
{
private bool $stopwatchIsAvailable;
public function __construct(bool $stopwatchIsAvailable)
{
$this->stopwatchIsAvailable = $stopwatchIsAvailable;
}
public function parse(Token $token): Node
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
// {% stopwatch 'bar' %}
$name = $this->parser->getExpressionParser()->parseExpression();
$stream->expect(Token::BLOCK_END_TYPE);
// {% endstopwatch %}
$body = $this->parser->subparse($this->decideStopwatchEnd(...), true);
$stream->expect(Token::BLOCK_END_TYPE);
if ($this->stopwatchIsAvailable) {
return new StopwatchNode($name, $body, class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : new AssignNameExpression($this->parser->getVarName(), $token->getLine()), $lineno, $this->getTag());
}
return $body;
}
public function decideStopwatchEnd(Token $token): bool
{
return $token->test('endstopwatch');
}
public function getTag(): string
{
return 'stopwatch';
}
}

View File

@@ -0,0 +1,39 @@
<?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\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\TransDefaultDomainNode;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Token Parser for the 'trans_default_domain' tag.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class TransDefaultDomainTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$expr = $this->parser->getExpressionParser()->parseExpression();
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
return new TransDefaultDomainNode($expr, $token->getLine(), $this->getTag());
}
public function getTag(): string
{
return 'trans_default_domain';
}
}

View File

@@ -0,0 +1,89 @@
<?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\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\TransNode;
use Twig\Error\SyntaxError;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Node;
use Twig\Node\TextNode;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Token Parser for the 'trans' tag.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class TransTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$count = null;
$vars = new ArrayExpression([], $lineno);
$domain = null;
$locale = null;
if (!$stream->test(Token::BLOCK_END_TYPE)) {
if ($stream->test('count')) {
// {% trans count 5 %}
$stream->next();
$count = $this->parser->getExpressionParser()->parseExpression();
}
if ($stream->test('with')) {
// {% trans with vars %}
$stream->next();
$vars = $this->parser->getExpressionParser()->parseExpression();
}
if ($stream->test('from')) {
// {% trans from "messages" %}
$stream->next();
$domain = $this->parser->getExpressionParser()->parseExpression();
}
if ($stream->test('into')) {
// {% trans into "fr" %}
$stream->next();
$locale = $this->parser->getExpressionParser()->parseExpression();
} elseif (!$stream->test(Token::BLOCK_END_TYPE)) {
throw new SyntaxError('Unexpected token. Twig was looking for the "with", "from", or "into" keyword.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
}
// {% trans %}message{% endtrans %}
$stream->expect(Token::BLOCK_END_TYPE);
$body = $this->parser->subparse($this->decideTransFork(...), true);
if (!$body instanceof TextNode && !$body instanceof AbstractExpression) {
throw new SyntaxError('A message inside a trans tag must be a simple text.', $body->getTemplateLine(), $stream->getSourceContext());
}
$stream->expect(Token::BLOCK_END_TYPE);
return new TransNode($body, $domain, $count, $vars, $locale, $lineno, $this->getTag());
}
public function decideTransFork(Token $token): bool
{
return $token->test(['endtrans']);
}
public function getTag(): string
{
return 'trans';
}
}

View File

@@ -0,0 +1,98 @@
<?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\Bridge\Twig\Translation;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Translation\Extractor\AbstractFileExtractor;
use Symfony\Component\Translation\Extractor\ExtractorInterface;
use Symfony\Component\Translation\MessageCatalogue;
use Twig\Environment;
use Twig\Error\Error;
use Twig\Source;
/**
* TwigExtractor extracts translation messages from a twig template.
*
* @author Michel Salib <michelsalib@hotmail.com>
* @author Fabien Potencier <fabien@symfony.com>
*/
class TwigExtractor extends AbstractFileExtractor implements ExtractorInterface
{
/**
* Default domain for found messages.
*/
private string $defaultDomain = 'messages';
/**
* Prefix for found message.
*/
private string $prefix = '';
private Environment $twig;
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
/**
* @return void
*/
public function extract($resource, MessageCatalogue $catalogue)
{
foreach ($this->extractFiles($resource) as $file) {
try {
$this->extractTemplate(file_get_contents($file->getPathname()), $catalogue);
} catch (Error) {
// ignore errors, these should be fixed by using the linter
}
}
}
/**
* @return void
*/
public function setPrefix(string $prefix)
{
$this->prefix = $prefix;
}
/**
* @return void
*/
protected function extractTemplate(string $template, MessageCatalogue $catalogue)
{
$visitor = $this->twig->getExtension(TranslationExtension::class)->getTranslationNodeVisitor();
$visitor->enable();
$this->twig->parse($this->twig->tokenize(new Source($template, '')));
foreach ($visitor->getMessages() as $message) {
$catalogue->set(trim($message[0]), $this->prefix.trim($message[0]), $message[1] ?: $this->defaultDomain);
}
$visitor->disable();
}
protected function canBeExtracted(string $file): bool
{
return $this->isFile($file) && 'twig' === pathinfo($file, \PATHINFO_EXTENSION);
}
protected function extractFromDirectory($directory): iterable
{
$finder = new Finder();
return $finder->files()->name('*.twig')->in($directory);
}
}

View File

@@ -0,0 +1,122 @@
<?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\Bridge\Twig;
use Composer\InstalledVersions;
use Symfony\Bundle\FullStack;
use Twig\Error\SyntaxError;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* @internal
*/
class UndefinedCallableHandler
{
private const FILTER_COMPONENTS = [
'humanize' => 'form',
'form_encode_currency' => 'form',
'trans' => 'translation',
'sanitize_html' => 'html-sanitizer',
'yaml_encode' => 'yaml',
'yaml_dump' => 'yaml',
];
private const FUNCTION_COMPONENTS = [
'asset' => 'asset',
'asset_version' => 'asset',
'importmap' => 'asset-mapper',
'dump' => 'debug-bundle',
'encore_entry_link_tags' => 'webpack-encore-bundle',
'encore_entry_script_tags' => 'webpack-encore-bundle',
'expression' => 'expression-language',
'form_widget' => 'form',
'form_errors' => 'form',
'form_label' => 'form',
'form_help' => 'form',
'form_row' => 'form',
'form_rest' => 'form',
'form' => 'form',
'form_start' => 'form',
'form_end' => 'form',
'csrf_token' => 'form',
'form_parent' => 'form',
'field_name' => 'form',
'field_value' => 'form',
'field_label' => 'form',
'field_help' => 'form',
'field_errors' => 'form',
'field_choices' => 'form',
'logout_url' => 'security-http',
'logout_path' => 'security-http',
'is_granted' => 'security-core',
'link' => 'web-link',
'preload' => 'web-link',
'dns_prefetch' => 'web-link',
'preconnect' => 'web-link',
'prefetch' => 'web-link',
'prerender' => 'web-link',
'workflow_can' => 'workflow',
'workflow_transitions' => 'workflow',
'workflow_transition' => 'workflow',
'workflow_has_marked_place' => 'workflow',
'workflow_marked_places' => 'workflow',
'workflow_metadata' => 'workflow',
'workflow_transition_blockers' => 'workflow',
];
private const FULL_STACK_ENABLE = [
'html-sanitizer' => 'enable "framework.html_sanitizer"',
'form' => 'enable "framework.form"',
'security-core' => 'add the "SecurityBundle"',
'security-http' => 'add the "SecurityBundle"',
'web-link' => 'enable "framework.web_link"',
'workflow' => 'enable "framework.workflows"',
];
public static function onUndefinedFilter(string $name): TwigFilter|false
{
if (!isset(self::FILTER_COMPONENTS[$name])) {
return false;
}
throw new SyntaxError(self::onUndefined($name, 'filter', self::FILTER_COMPONENTS[$name]));
}
public static function onUndefinedFunction(string $name): TwigFunction|false
{
if (!isset(self::FUNCTION_COMPONENTS[$name])) {
return false;
}
if ('webpack-encore-bundle' === self::FUNCTION_COMPONENTS[$name]) {
return new TwigFunction($name, static fn () => '');
}
throw new SyntaxError(self::onUndefined($name, 'function', self::FUNCTION_COMPONENTS[$name]));
}
private static function onUndefined(string $name, string $type, string $component): string
{
if (class_exists(FullStack::class) && isset(self::FULL_STACK_ENABLE[$component])) {
return sprintf('Did you forget to %s? Unknown %s "%s".', self::FULL_STACK_ENABLE[$component], $type, $name);
}
$missingPackage = 'symfony/'.$component;
if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($missingPackage)) {
$missingPackage = 'symfony/twig-bundle';
}
return sprintf('Did you forget to run "composer require %s"? Unknown %s "%s".', $missingPackage, $type, $name);
}
}

View File

@@ -0,0 +1,76 @@
{
"name": "symfony/twig-bridge",
"type": "symfony-bridge",
"description": "Provides integration for Twig with various Symfony components",
"keywords": [],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/translation-contracts": "^2.5|^3",
"twig/twig": "^2.13|^3.0.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"symfony/asset": "^5.4|^6.0|^7.0",
"symfony/asset-mapper": "^6.3|^7.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/finder": "^5.4|^6.0|^7.0",
"symfony/form": "^6.4|^7.0",
"symfony/html-sanitizer": "^6.1|^7.0",
"symfony/http-foundation": "^5.4|^6.0|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/intl": "^5.4|^6.0|^7.0",
"symfony/mime": "^6.2|^7.0",
"symfony/polyfill-intl-icu": "~1.0",
"symfony/property-info": "^5.4|^6.0|^7.0",
"symfony/routing": "^5.4|^6.0|^7.0",
"symfony/translation": "^6.1|^7.0",
"symfony/yaml": "^5.4|^6.0|^7.0",
"symfony/security-acl": "^2.8|^3.0",
"symfony/security-core": "^5.4|^6.0|^7.0",
"symfony/security-csrf": "^5.4|^6.0|^7.0",
"symfony/security-http": "^5.4|^6.0|^7.0",
"symfony/serializer": "^6.4.3|^7.0.3",
"symfony/stopwatch": "^5.4|^6.0|^7.0",
"symfony/console": "^5.4|^6.0|^7.0",
"symfony/expression-language": "^5.4|^6.0|^7.0",
"symfony/web-link": "^5.4|^6.0|^7.0",
"symfony/workflow": "^5.4|^6.0|^7.0",
"twig/cssinliner-extra": "^2.12|^3",
"twig/inky-extra": "^2.12|^3",
"twig/markdown-extra": "^2.12|^3"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0",
"symfony/console": "<5.4",
"symfony/form": "<6.3",
"symfony/http-foundation": "<5.4",
"symfony/http-kernel": "<6.4",
"symfony/mime": "<6.2",
"symfony/serializer": "<6.4",
"symfony/translation": "<5.4",
"symfony/workflow": "<5.4"
},
"autoload": {
"psr-4": { "Symfony\\Bridge\\Twig\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}