Skip to content

Commit

Permalink
Modern fragment foundation (see #4393)
Browse files Browse the repository at this point in the history
Description
-----------

-

Commits
-------

046cb0f prefer legacy element when using fragment with same name
bb86d9d always set the template in the RegisterFragmentsPass
d481dcd create a new FragmentTemplate
1b44558 allow creating enhanced contexts from plain arrays
b3d33e9 rework rendering fragments in the AbstractFragmentController
6a1504a rework default template context for modern fragments
7ceab8a deprecate the old template class, so that its usage in the old getRes…
d5aa393 rework back end wildcard
52284e8 adjust and add tests
bea6eef fix quoting in the TemplateLoader
31f1084 fix a discouraged usage in the TwoFactorController
3e3cb31 rename headline attributes
d9498d0 fix testReturnsWildCardInBackendScope test
6187b54 add clarifying comment
1fe0bf9 enable deprecation warning
9ad7872 rename 'back end' to 'backend'
23fecb1 expect self deprecations
6ed574a Revert "expect self deprecations"
8e74730 remove triggering deprecation for legacy fragment templates for now
d0d73f1 enforce type hints
b498e2d Revert "enforce type hints"
f5bf545 apply changes from #4536 + adjust tests
03ff8ad Merge remote-tracking branch 'origin/5.x' into modern-fragment-founda…
facfa23 drop removed methods
a3d3aed Update core-bundle/tests/Twig/FragmentTemplateTest.php
8815efd Adjust the deprecation message

Co-authored-by: Leo Feyer <github@contao.org>
  • Loading branch information
m-vo and leofeyer committed Apr 26, 2022
1 parent f401a50 commit fabb13c
Show file tree
Hide file tree
Showing 22 changed files with 1,413 additions and 319 deletions.
155 changes: 133 additions & 22 deletions core-bundle/src/Controller/AbstractFragmentController.php
Expand Up @@ -12,22 +12,28 @@

namespace Contao\CoreBundle\Controller;

use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\EventListener\SubrequestCacheSubscriber;
use Contao\CoreBundle\Fragment\FragmentOptionsAwareInterface;
use Contao\CoreBundle\Routing\ScopeMatcher;
use Contao\FragmentTemplate;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\CoreBundle\Twig\Interop\ContextFactory;
use Contao\CoreBundle\Twig\Loader\ContaoFilesystemLoader;
use Contao\FrontendTemplate;
use Contao\Model;
use Contao\PageModel;
use Contao\StringUtil;
use Contao\Template;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractFragmentController extends AbstractController implements FragmentOptionsAwareInterface
{
protected array $options = [];
private string|null $view = null;

public function setFragmentOptions(array $options): void
{
Expand All @@ -43,6 +49,8 @@ public static function getSubscribedServices(): array

$services['request_stack'] = RequestStack::class;
$services['contao.routing.scope_matcher'] = ScopeMatcher::class;
$services['contao.twig.filesystem_loader'] = ContaoFilesystemLoader::class;
$services['contao.twig.interop.context_factory'] = ContextFactory::class;

return $services;
}
Expand All @@ -59,47 +67,87 @@ protected function getPageModel(): PageModel|null
}

/**
* Creates a template by name or from the "customTpl" field of the model.
* Creates a FragmentTemplate container object by template name or from the
* "customTpl" field of the model and registers the effective template as
* default view when using render().
*
* Calling getResponse() on the returned object will internally call
* render() with the set parameters and return the response.
*
* Note: The $fallbackTemplateName argument will be removed in Contao 6;
* always set a template via the fragment options, instead.
*/
protected function createTemplate(Model $model, string $templateName): Template
protected function createTemplate(Model $model, string|null $fallbackTemplateName = null): FragmentTemplate
{
if (isset($this->options['template'])) {
$templateName = $this->options['template'];
}
$templateName = $this->getTemplateName($model, $fallbackTemplateName);
$isLegacyTemplate = $this->isLegacyTemplate($templateName);

$request = $this->container->get('request_stack')->getCurrentRequest();
// Allow calling render() without a view
$this->view = !$isLegacyTemplate ? "@Contao/$templateName.html.twig" : null;

$onGetResponse = function (FragmentTemplate $template, Response|null $preBuiltResponse) use ($templateName, $isLegacyTemplate): Response {
if ($isLegacyTemplate) {
// Render using the legacy framework
$legacyTemplate = $this->container->get('contao.framework')->createInstance(FrontendTemplate::class, [$templateName]);
$legacyTemplate->setData($template->getData());

$response = $legacyTemplate->getResponse();

if (null !== $preBuiltResponse) {
return $preBuiltResponse->setContent($response->getContent());
}

$this->markResponseForInternalCaching($response);

if ($model->customTpl) {
// Use the custom template unless it is a back end request
if (null === $request || !$this->container->get('contao.routing.scope_matcher')->isBackendRequest($request)) {
$templateName = $model->customTpl;
return $response;
}
}

$templateClass = FragmentTemplate::class;
// Directly render with Twig
$context = $this->container->get('contao.twig.interop.context_factory')->fromData($template->getData());

// Current request is the main request (e.g. ESI fragment), so we have to replace
// insert tags etc. on the template output
if ($request === $this->container->get('request_stack')->getMainRequest()) {
$templateClass = FrontendTemplate::class;
}
return $this->render($template->getName(), $context, $preBuiltResponse);
};

$template = new FragmentTemplate($templateName, $onGetResponse);

/** @var Template $template */
$template = $this->container->get('contao.framework')->createInstance($templateClass, [$templateName]);
$template->setData($model->row());
if ($isLegacyTemplate) {
$template->setData($model->row());
}

return $template;
}

/**
* @internal
*/
protected function isLegacyTemplate(string $templateName): bool
{
return !str_contains($templateName, '/');
}

/**
* @internal the addHeadlineToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Headline data is
* always added to the context of modern fragment templates.
*/
protected function addHeadlineToTemplate(Template $template, array|string|null $headline): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$data = StringUtil::deserialize($headline);
$template->headline = \is_array($data) ? $data['value'] : $data;
$template->hl = \is_array($data) ? $data['unit'] : 'h1';
}

/**
* @internal the addCssAttributesToTemplate() method is considered internal
* in Contao 5 and won't be accessible anymore in Contao 6. Attributes data
* is always added to the context of modern fragment templates.
*/
protected function addCssAttributesToTemplate(Template $template, string $templateName, array|string|null $cssID, array $classes = null): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$data = StringUtil::deserialize($cssID, true);
$template->class = trim($templateName.' '.($data[1] ?? ''));
$template->cssID = !empty($data[0]) ? ' id="'.$data[0].'"' : '';
Expand All @@ -109,20 +157,38 @@ protected function addCssAttributesToTemplate(Template $template, string $templa
}
}

/**
* @internal the addPropertiesToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Custom properties
* are always added to the context of modern fragment templates.
*/
protected function addPropertiesToTemplate(Template $template, array $properties): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

foreach ($properties as $k => $v) {
$template->{$k} = $v;
}
}

/**
* @internal the addSectionToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Section data is
* always added to the context of modern fragment templates.
*/
protected function addSectionToTemplate(Template $template, string $section): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$template->inColumn = $section;
}

/**
* Returns the type from the class name.
*
* @internal the getType() method is considered internal in Contao 5 and
* won't be accessible anymore in Contao 6. Retrieve the type from the
* fragment options instead.
*/
protected function getType(): string
{
Expand All @@ -139,8 +205,19 @@ protected function getType(): string
return Container::underscore($className);
}

protected function render(string $view, array $parameters = [], Response $response = null): Response
/**
* Renders a template. If $view is set to null, the default template of
* this fragment will be rendered.
*
* By default, the returned response will have the appropriate headers set,
* that allow our SubrequestCacheSubscriber to merge it with others of the
* same page. Pass a prebuilt Response if you want to have full control -
* no headers will be set then.
*/
protected function render(string|null $view = null, array $parameters = [], Response $response = null): Response
{
$view ??= $this->view ?? throw new \InvalidArgumentException('Cannot derive template name, please make sure createTemplate() was called before or specify the template explicitly.');

if (null === $response) {
$response = new Response();

Expand All @@ -150,6 +227,13 @@ protected function render(string $view, array $parameters = [], Response $respon
return parent::render($view, $parameters, $response);
}

protected function isBackendScope(Request $request = null): bool
{
$request ??= $this->container->get('request_stack')->getCurrentRequest();

return null !== $request && $this->container->get('contao.routing.scope_matcher')->isBackendRequest($request);
}

/**
* Marks the response to affect the caching of the current page but removes any default cache header.
*/
Expand All @@ -158,4 +242,31 @@ protected function markResponseForInternalCaching(Response $response): void
$response->headers->set(SubrequestCacheSubscriber::MERGE_CACHE_HEADER, '1');
$response->headers->remove('Cache-Control');
}

private function triggerDeprecationIfCallingFromCustomClass(string $method): void
{
$caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'];

if (!\in_array($caller, [AbstractContentElementController::class, AbstractFrontendModuleController::class], true)) {
trigger_deprecation('contao/core-bundle', '5.0', 'The "%s" method is considered internal and won\'t be accessible anymore in Contao 6.', $method);
}
}

private function getTemplateName(Model $model, string|null $fallbackTemplateName): string
{
// If set, use the custom template unless it is a back end request
if ($model->customTpl && !$this->isBackendScope()) {
return $model->customTpl;
}

$definedTemplateName = $this->options['template'] ?? null;

// Always use the defined name for legacy templates and for modern
// templates that exist (= those that do not need to have a fallback)
if (null !== $definedTemplateName && ($this->isLegacyTemplate($definedTemplateName) || $this->container->get('contao.twig.filesystem_loader')->exists("@Contao/$definedTemplateName.html.twig"))) {
return $definedTemplateName;
}

return $fallbackTemplateName ?? throw new \InvalidArgumentException('No template was set in the fragment options.');
}
}
Expand Up @@ -14,21 +14,27 @@

use Contao\ContentModel;
use Contao\CoreBundle\Controller\AbstractFragmentController;
use Contao\Template;
use Contao\CoreBundle\String\HtmlAttributes;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\StringUtil;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractContentElementController extends AbstractFragmentController
{
public function __invoke(Request $request, ContentModel $model, string $section, array $classes = null): Response
{
$type = $this->getType();
$template = $this->createTemplate($model, 'ce_'.$type);
$template = $this->createTemplate($model, 'ce_'.$this->getType());

$this->addDefaultDataToTemplate(
$template,
$model->row(),
$section,
$classes ?? [],
$request->attributes->get('templateProperties', []),
$this->isBackendScope($request)
);

$this->addHeadlineToTemplate($template, $model->headline);
$this->addCssAttributesToTemplate($template, 'ce_'.$type, $model->cssID, $classes);
$this->addPropertiesToTemplate($template, $request->attributes->get('templateProperties', []));
$this->addSectionToTemplate($template, $section);
$this->tagResponse($model);

return $this->getResponse($template, $model, $request);
Expand All @@ -54,5 +60,44 @@ protected function addSharedMaxAgeToResponse(Response $response, ContentModel $m
$response->setSharedMaxAge(min($min));
}

abstract protected function getResponse(Template $template, ContentModel $model, Request $request): Response;
/**
* Add default content element data to the template context.
*
* @param array<string, mixed> $modelData
* @param array<string> $classes
* @param array<string, mixed> $properties
*/
protected function addDefaultDataToTemplate(FragmentTemplate $template, array $modelData = [], string $section = 'main', array $classes = [], array $properties = [], bool $asOverview = false): void
{
if ($this->isLegacyTemplate($template->getName())) {
// Legacy fragments
$this->addHeadlineToTemplate($template, $modelData['headline'] ?? null);
$this->addCssAttributesToTemplate($template, $template->getName(), $modelData['cssID'] ?? null, $classes);
$this->addPropertiesToTemplate($template, $properties);
$this->addSectionToTemplate($template, $section);

return;
}

$headlineData = StringUtil::deserialize($modelData['headline'] ?? [] ?: '', true);
$attributesData = StringUtil::deserialize($modelData['cssID'] ?? [] ?: '', true);

$template->setData([
'type' => $this->getType(),
'template' => $template->getName(),
'as_overview' => $asOverview,
'data' => $modelData,
'section' => $section,
'properties' => $properties,
'attributes' => (new HtmlAttributes())
->setIfExists('id', $attributesData[0] ?? null)
->addClass($attributesData[1] ?? '', ...$classes),
'headline' => [
'text' => $headlineData['value'] ?? '',
'tagName' => $headlineData['unit'] ?? 'h1',
],
]);
}

abstract protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response;
}

0 comments on commit fabb13c

Please sign in to comment.