Skip to content

Commit eb82264

Browse files
authored
Merge pull request #137 from s2b/feature/namedSlots
[FEATURE] Named slots with fc:content ViewHelper
2 parents b0284b1 + 212c79f commit eb82264

File tree

14 files changed

+472
-13
lines changed

14 files changed

+472
-13
lines changed

Classes/Command/CheckContentEscapingCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public function detectRawContentVariable(NodeInterface $node, array $parents = [
140140
$childNode = $this->resolveEscapingNode($childNode);
141141

142142
// Check all parent elements of content variable
143-
if ($childNode instanceof ObjectAccessorNode && $childNode->getObjectPath() === 'content') {
143+
if ($childNode instanceof ObjectAccessorNode && $childNode->getObjectPath() === ComponentRenderer::DEFAULT_SLOT) {
144144
for ($i = $lastParent; $i >= 0; $i--) {
145145
// Skip all non-viewhelpers
146146
if (!($parents[$i] instanceof ViewHelperNode)) {
@@ -187,7 +187,7 @@ public function detectEscapedVariablesPassedAsContent(NodeInterface $node): arra
187187
isset($this->affectedComponents[$viewHelper->getComponentNamespace()])
188188
) {
189189
// Check if variables were used inside of content parameter
190-
$contentNode = $childNode->getArguments()['content'] ?? $childNode;
190+
$contentNode = $childNode->getArguments()[ComponentRenderer::DEFAULT_SLOT] ?? $childNode;
191191
$variableNames = $this->checkForVariablesWithoutRaw($contentNode);
192192
if (!empty($variableNames)) {
193193
$results[] = [
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
declare(strict_types=1);
3+
namespace SMS\FluidComponents\Domain\Model;
4+
5+
/**
6+
* @internal
7+
*/
8+
final class RequiredSlotPlaceholder extends Slot
9+
{
10+
public function __construct()
11+
{
12+
parent::__construct('');
13+
}
14+
15+
public static function __set_state(array $properties): self
16+
{
17+
return new self;
18+
}
19+
}

Classes/Domain/Model/Slot.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
namespace SMS\FluidComponents\Domain\Model;
44

5+
use SMS\FluidComponents\Interfaces\ConstructibleFromClosure;
56
use SMS\FluidComponents\Interfaces\ConstructibleFromString;
67
use SMS\FluidComponents\Interfaces\EscapedParameter;
78

89
/**
910
* Data Structure to encapsulate html markup provided to a component
1011
*/
11-
class Slot implements EscapedParameter, ConstructibleFromString, \Countable
12+
class Slot implements EscapedParameter, ConstructibleFromString, ConstructibleFromClosure, \Countable
1213
{
1314
protected $html;
1415

@@ -22,6 +23,11 @@ public static function fromString(string $html): Slot
2223
return new Slot($html);
2324
}
2425

26+
public static function fromClosure(\Closure $closure): Slot
27+
{
28+
return new Slot($closure());
29+
}
30+
2531
public function count(): int
2632
{
2733
return strlen($this->html);

Classes/Fluid/ViewHelper/ComponentRenderer.php

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace SMS\FluidComponents\Fluid\ViewHelper;
44

55
use Psr\Container\ContainerInterface;
6+
use SMS\FluidComponents\Domain\Model\RequiredSlotPlaceholder;
67
use SMS\FluidComponents\Domain\Model\Slot;
78
use SMS\FluidComponents\Interfaces\ComponentAware;
89
use SMS\FluidComponents\Interfaces\EscapedParameter;
@@ -13,6 +14,7 @@
1314
use SMS\FluidComponents\Utility\ComponentPrefixer\GenericComponentPrefixer;
1415
use SMS\FluidComponents\Utility\ComponentSettings;
1516
use SMS\FluidComponents\ViewHelpers\ComponentViewHelper;
17+
use SMS\FluidComponents\ViewHelpers\ContentViewHelper;
1618
use SMS\FluidComponents\ViewHelpers\ParamViewHelper;
1719
use TYPO3\CMS\Core\Configuration\Features;
1820
use TYPO3\CMS\Core\Information\Typo3Version;
@@ -31,10 +33,12 @@
3133

3234
class ComponentRenderer extends AbstractViewHelper
3335
{
36+
const DEFAULT_SLOT = 'content';
37+
3438
protected $reservedArguments = [
3539
'class',
3640
'component',
37-
'content',
41+
self::DEFAULT_SLOT,
3842
'settings',
3943
];
4044

@@ -197,14 +201,15 @@ public function render()
197201
]);
198202
$variableContainer->add('settings', $this->componentSettings);
199203

200-
// Provide component content to renderer
201-
if (!isset($this->arguments['content'])) {
202-
$this->arguments['content'] = (string)$this->renderChildren();
203-
}
204-
205204
// Provide supplied arguments from component call to renderer
206-
foreach ($this->arguments as $name => $argument) {
207-
$argumentType = $this->argumentDefinitions[$name]->getType();
205+
foreach ($this->argumentDefinitions as $name => $definition) {
206+
$argumentType = $definition->getType();
207+
208+
if (is_a($argumentType, Slot::class, true)) {
209+
$argument = $this->renderSlot($name);
210+
} else {
211+
$argument = $this->arguments[$name] ?? null;
212+
}
208213

209214
$argument = $this->componentArgumentConverter->convertValueToType($argument, $argumentType);
210215

@@ -237,6 +242,42 @@ function () use ($componentFile) {
237242
return $this->parsedTemplate->render($renderingContext);
238243
}
239244

245+
protected function renderSlot(string $name)
246+
{
247+
$slot = $this->arguments[$name] ?? null;
248+
249+
// Shortcut if template is rendered from cache
250+
// or parameter was provided directly to the component
251+
if (isset($slot) && !$slot instanceof RequiredSlotPlaceholder) {
252+
return $slot;
253+
}
254+
255+
// Use content specified by <fc:content /> ViewHelpers
256+
// This is only executed for uncached templates
257+
if (isset($this->viewHelperNode)) {
258+
$contentViewHelpers = $this->extractContentViewHelpers($this->viewHelperNode, $this->renderingContext);
259+
if (isset($contentViewHelpers[$name])) {
260+
return (string) $contentViewHelpers[$name]->evaluateChildNodes($this->renderingContext);
261+
}
262+
}
263+
264+
// Use tag content for default slot
265+
if ($name === self::DEFAULT_SLOT) {
266+
return (string) $this->renderChildren();
267+
}
268+
269+
// Required Slot parameters are checked here for existence at last
270+
if ($slot instanceof RequiredSlotPlaceholder) {
271+
throw new \InvalidArgumentException(sprintf(
272+
'Slot "%s" is required by component "%s", but no value was given.',
273+
$name,
274+
$this->componentNamespace
275+
), 1681728555);
276+
}
277+
278+
return $slot;
279+
}
280+
240281
/**
241282
* Overwrites original compilation to store component namespace in compiled templates
242283
*
@@ -254,6 +295,27 @@ public function compile(
254295
ViewHelperNode $node,
255296
TemplateCompiler $compiler
256297
) {
298+
$allowedSlots = [];
299+
foreach ($node->getArgumentDefinitions() as $definition) {
300+
if (is_a($definition->getType(), Slot::class, true)) {
301+
$allowedSlots[$definition->getName()] = true;
302+
}
303+
}
304+
305+
$contentViewHelpers = $this->extractContentViewHelpers($node, $compiler->getRenderingContext());
306+
foreach ($contentViewHelpers as $slotName => $viewHelperNode) {
307+
if (!isset($allowedSlots[$slotName])) {
308+
throw new \InvalidArgumentException(sprintf(
309+
'Slot "%s" does not exist in component "%s", but was used as named slot.',
310+
$slotName,
311+
$this->componentNamespace
312+
), 1681832624);
313+
}
314+
315+
$childNodesAsClosure = $compiler->wrapChildNodesInClosure($viewHelperNode);
316+
$initializationPhpCode .= sprintf('%s[\'%s\'] = %s;', $argumentsName, $slotName, $childNodesAsClosure) . chr(10);
317+
}
318+
257319
return sprintf(
258320
'%s::renderComponent(%s, %s, $renderingContext, %s)',
259321
get_class($this),
@@ -304,7 +366,7 @@ public function initializeArguments()
304366
'Additional CSS classes for the component'
305367
);
306368
$this->registerArgument(
307-
'content',
369+
self::DEFAULT_SLOT,
308370
Slot::class,
309371
'Main content of the component; falls back to ViewHelper tag content',
310372
false,
@@ -465,19 +527,49 @@ protected function initializeComponentParams()
465527
$optional = $param['optional'] ?? false;
466528
$description = $param['description'] ?? '';
467529
$escape = is_subclass_of($param['type'], EscapedParameter::class) ? true : null;
530+
531+
// Special handling for required Slot parameters
532+
// This is necessary to be able to use <fc:content /> instead of a component parameter because
533+
// the Fluid parser checks for existing arguments early in the parsing process
534+
if (is_a($param['type'], Slot::class, true) && !$optional) {
535+
$optional = true;
536+
$param['default'] = new RequiredSlotPlaceholder;
537+
}
538+
468539
$this->registerArgument($param['name'], $param['type'], $description, !$optional, $param['default'], $escape);
469540
}
470541
}
471542
}
472543

544+
/**
545+
* Extracts all <fc:content /> ViewHelpers from Fluid template node
546+
*
547+
* @param NodeInterface $node
548+
* @param RenderingContext $renderingContext
549+
* @return array
550+
*/
551+
protected function extractContentViewHelpers(NodeInterface $node, RenderingContext $renderingContext): array
552+
{
553+
return array_reduce(
554+
$this->extractViewHelpers($node, ContentViewHelper::class),
555+
function (array $nodes, ViewHelperNode $node) use ($renderingContext) {
556+
$slotArgument = $node->getArguments()['slot'] ?? null;
557+
$slotName = ($slotArgument) ? $slotArgument->evaluate($renderingContext) : self::DEFAULT_SLOT;
558+
$nodes[$slotName] = $node;
559+
return $nodes;
560+
},
561+
[]
562+
);
563+
}
564+
473565
/**
474566
* Extract all ViewHelpers of a certain type from a Fluid template node
475567
*
476568
* @param NodeInterface $node
477569
* @param string $viewHelperClassName
478570
* @return array
479571
*/
480-
protected function extractViewHelpers(NodeInterface $node, string $viewHelperClassName)
572+
protected function extractViewHelpers(NodeInterface $node, string $viewHelperClassName): array
481573
{
482574
$viewHelperNodes = [];
483575

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace SMS\FluidComponents\Interfaces;
4+
5+
interface ConstructibleFromClosure
6+
{
7+
public static function fromClosure(\Closure $value);
8+
}

Classes/Utility/ComponentArgumentConverter.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace SMS\FluidComponents\Utility;
44

55
use SMS\FluidComponents\Interfaces\ConstructibleFromArray;
6+
use SMS\FluidComponents\Interfaces\ConstructibleFromClosure;
67
use SMS\FluidComponents\Interfaces\ConstructibleFromDateTime;
78
use SMS\FluidComponents\Interfaces\ConstructibleFromDateTimeImmutable;
89
use SMS\FluidComponents\Interfaces\ConstructibleFromExtbaseFile;
@@ -40,6 +41,10 @@ class ComponentArgumentConverter implements \TYPO3\CMS\Core\SingletonInterface
4041
ConstructibleFromNull::class,
4142
'fromNull'
4243
],
44+
\Closure::class => [
45+
ConstructibleFromClosure::class,
46+
'fromClosure'
47+
],
4348
'DateTime' => [
4449
ConstructibleFromDateTime::class,
4550
'fromDateTime'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace SMS\FluidComponents\ViewHelpers;
4+
5+
use SMS\FluidComponents\Fluid\ViewHelper\ComponentRenderer;
6+
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
7+
use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\ParserRuntimeOnly;
8+
9+
class ContentViewHelper extends AbstractViewHelper
10+
{
11+
use ParserRuntimeOnly;
12+
13+
protected $escapeChildren = true;
14+
15+
public function initializeArguments()
16+
{
17+
$this->registerArgument('slot', 'string', 'Slot name', false, ComponentRenderer::DEFAULT_SLOT);
18+
}
19+
}

Documentation/DataStructures.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,17 @@ Fluid Component `Molecule/TeaserCard/TeaserCard.html`:
413413
<my:molecule.teaserCard buttons="<button>read more about ABC</button>{insecure}" />
414414
```
415415

416+
You can also use the `<fc:content />` ViewHelper to specify HTML markup more easily:
417+
418+
```xml
419+
<my:molecule.teaserCard>
420+
<fc:content slot="buttons">
421+
<button>read more about ABC</button>
422+
<button>read more about DEF</button>
423+
</fc:content>
424+
</my:molecule.teaserCard>
425+
```
426+
416427
## Type Aliases
417428

418429
The included data structures can also be defined with their alias. These are `Image`, `Link`, `Typolink`, `Labels`, `Navigation` and `NavigationItem`.

Documentation/ViewHelperReference.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,31 @@ default value can alternatively be defined in the `fc:slot` tag content.
136136
</fc:component>
137137
```
138138

139+
### Content ViewHelper ("named slots")
140+
141+
The `fc:content` ViewHelper improves the way you can pass HTML content to several component slots. When calling a
142+
component, you can use one or more `fc:content` calls inside the component tag to specify the values of slots. In
143+
popular frontend frameworks as well as the web components standard, this is referred to as *named slots*.
144+
145+
#### Arguments
146+
147+
* `slot` (default: `content`): Name of the slot parameter that should be set
148+
149+
#### Examples
150+
151+
```xml
152+
<my:molecule.teaser link="https://typo3.org">
153+
<!-- accessible by {fc:slot()} in the component -->
154+
<fc:content>
155+
The <b>professional, flexible</b> Content Management System
156+
</fc:content>
157+
<!-- accessible by {fc:slot(name: 'buttons')} in the component -->
158+
<fc:content slot="buttons">
159+
<button>read more</button>
160+
</fc:content>
161+
</my:molecule.teaser>
162+
```
163+
139164

140165
## Translations
141166

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<fc:component>
2+
<fc:param name="slot" type="Slot" optional="1" />
3+
<fc:renderer>
4+
<fc:slot name="slot" />
5+
</fc:renderer>
6+
</fc:component>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<fc:component>
2+
<fc:param name="slot" type="Slot" optional="1" />
3+
<fc:renderer>
4+
<f:if condition="{slot}"><f:then>defined</f:then><f:else>undefined</f:else></f:if>
5+
</fc:renderer>
6+
</fc:component>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<fc:component>
2+
<fc:param name="slot1" type="Slot" />
3+
<fc:param name="slot2" type="Slot" />
4+
<fc:renderer>
5+
<fc:slot name="slot1" />|<fc:slot name="slot2" />|<fc:slot />
6+
</fc:renderer>
7+
</fc:component>

0 commit comments

Comments
 (0)