Skip to content

Commit 0eb9f8e

Browse files
authored
Introduce #[AsBlock] attribute (#16)
1 parent 1414708 commit 0eb9f8e

21 files changed

+996
-7
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,67 @@ This will replace `StoriesApi` to `StoriesResolvedApi`. The `StoriesResolvedApi`
181181
> [Storyblok docs](https://www.storyblok.com/docs/api/content-delivery/v2/stories/retrieve-a-single-story)
182182
> for more information
183183

184+
## Block Registration with `#[AsBlock]`
185+
186+
You can register Storyblok blocks using the `#[AsBlock]` attribute.
187+
188+
The `name` and `template` parameters are optional, you will find their defaults in the following section.
189+
190+
### Usage
191+
192+
To define a block, use the attribute on a class:
193+
194+
```php
195+
use Storyblok\Bundle\Block\Attribute\AsBlock;
196+
use Webmozart\Assert\Assert;
197+
198+
#[AsBlock(name: 'sample', template: 'custom_blocks/sample.html.twig')]
199+
final readonly class SampleBlock
200+
{
201+
public string $title;
202+
public string $description;
203+
204+
public function __construct(array $values)
205+
{
206+
Assert::keyExists($values, 'title');
207+
$this->title = $values['title'];
208+
209+
Assert::keyExists($values, 'description');
210+
$this->description = $values['description'];
211+
}
212+
}
213+
```
214+
215+
### Attribute Parameters
216+
217+
| Parameter | Type | Required? | Description |
218+
|------------|--------|-----------|-------------|
219+
| `name` | `string` | No | The block name used in Storyblok. Defaults to the class name converted to snake_case. |
220+
| `template` | `string` | No | The Twig template for rendering the block. Defaults to `blocks/{name}.html.twig`. |
221+
222+
### Customizing the Default Template Path
223+
224+
You can change the default template path structure by configuring it in `storyblok.yaml`:
225+
226+
```yaml
227+
# config/packages/storyblok.yaml
228+
storyblok:
229+
blocks_template_path: 'my/custom/path'
230+
```
231+
232+
### Rendering Blocks in Twig
233+
234+
A new `render_block` Twig filter allows easy rendering of Storyblok blocks:
235+
236+
```twig
237+
{% for block in page.body %}
238+
{% if block is not null %}
239+
{{ block|render_block }}
240+
{% endif %}
241+
{% endfor %}
242+
```
243+
244+
This ensures dynamic rendering of Storyblok components with minimal effort.
184245

185246
#### Best Practices
186247

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"symfony/http-client": "^6.0 || ^7.0",
2121
"symfony/http-kernel": "^6.0 || ^7.0",
2222
"symfony/monolog-bundle": "^3.10",
23+
"symfony/string": "^6.0 || ^7.0",
2324
"thecodingmachine/safe": "^2.0 || ^3.0",
25+
"twig/twig": "^3.20",
2426
"webmozart/assert": "^1.11"
2527
},
2628
"require-dev": {

config/services.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
66

77
use Storyblok\Api\StoryblokClientInterface;
8+
use Storyblok\Bundle\Block\BlockRegistry;
9+
use Storyblok\Bundle\Block\Renderer\BlockRenderer;
10+
use Storyblok\Bundle\Block\Renderer\RendererInterface;
811
use Storyblok\Bundle\Controller\WebhookController;
912
use Storyblok\Bundle\DataCollector\StoryblokCollector;
1013
use Storyblok\Bundle\Listener\UpdateProfilerListener;
@@ -17,6 +20,7 @@
1720
use Storyblok\Api\StoryblokClient;
1821
use Storyblok\Api\TagsApi;
1922
use Storyblok\Api\TagsApiInterface;
23+
use Storyblok\Bundle\Twig\BlockExtension;
2024
use Storyblok\Bundle\Webhook\WebhookEventHandlerChain;
2125
use Symfony\Component\HttpClient\HttpClient;
2226
use Symfony\Component\HttpClient\ScopingHttpClient;
@@ -92,5 +96,13 @@
9296
'method' => 'onKernelResponse',
9397
'priority' => -255,
9498
])
99+
100+
->set(BlockRenderer::class)
101+
->alias(RendererInterface::class, BlockRenderer::class)
102+
103+
->set(BlockRegistry::class)
104+
105+
->set(BlockExtension::class)
106+
->tag('twig.extension')
95107
;
96108
};

phpstan-baseline.neon

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,55 @@
11
parameters:
22
ignoreErrors:
33
-
4-
message: "#^Method Storyblok\\\\Bundle\\\\DataCollector\\\\StoryblokCollector\\:\\:collectOnClient\\(\\) return type has no value type specified in iterable type array\\.$#"
4+
message: '#^Call to static method Webmozart\\Assert\\Assert\:\:stringNotEmpty\(\) with class\-string will always evaluate to true\.$#'
5+
identifier: staticMethod.alreadyNarrowedType
6+
count: 1
7+
path: src/Block/BlockDefinition.php
8+
9+
-
10+
message: '#^Method Storyblok\\Bundle\\DataCollector\\StoryblokCollector\:\:collectOnClient\(\) return type has no value type specified in iterable type array\.$#'
11+
identifier: missingType.iterableValue
512
count: 1
613
path: src/DataCollector/StoryblokCollector.php
714

815
-
9-
message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#"
16+
message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:children\(\)\.$#'
17+
identifier: method.notFound
1018
count: 1
1119
path: src/DependencyInjection/Configuration.php
1220

1321
-
14-
message: "#^Method Storyblok\\\\Bundle\\\\Tests\\\\Unit\\\\Listener\\\\UpdateProfilerListenerTest\\:\\:createKernel\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
22+
message: '#^Parameter \#2 \$configurator of method Symfony\\Component\\DependencyInjection\\ContainerBuilder\:\:registerAttributeForAutoconfiguration\(\) expects callable\(Symfony\\Component\\DependencyInjection\\ChildDefinition, Storyblok\\Bundle\\Block\\Attribute\\AsBlock, Reflector\)\: void, Closure\(Symfony\\Component\\DependencyInjection\\ChildDefinition, Storyblok\\Bundle\\Block\\Attribute\\AsBlock, ReflectionClass\)\: void given\.$#'
23+
identifier: argument.type
24+
count: 1
25+
path: src/DependencyInjection/StoryblokExtension.php
26+
27+
-
28+
message: '#^Parameter \#2 \$className of class Storyblok\\Bundle\\Block\\BlockDefinition constructor expects class\-string, string given\.$#'
29+
identifier: argument.type
30+
count: 2
31+
path: tests/Unit/Block/BlockDefinitionTest.php
32+
33+
-
34+
message: '#^Method Storyblok\\Bundle\\Tests\\Unit\\Listener\\UpdateProfilerListenerTest\:\:createKernel\(\) has parameter \$options with no value type specified in iterable type array\.$#'
35+
identifier: missingType.iterableValue
1536
count: 1
1637
path: tests/Unit/Listener/UpdateProfilerListenerTest.php
1738

1839
-
19-
message: "#^Method Storyblok\\\\Bundle\\\\Tests\\\\Util\\\\TestKernel\\:\\:create\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
40+
message: '#^Method Storyblok\\Bundle\\Tests\\Util\\TestKernel\:\:create\(\) has parameter \$options with no value type specified in iterable type array\.$#'
41+
identifier: missingType.iterableValue
2042
count: 1
2143
path: tests/Util/TestKernel.php
2244

2345
-
24-
message: "#^Method Storyblok\\\\Bundle\\\\Tests\\\\Util\\\\TestKernel\\:\\:debug\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
46+
message: '#^Method Storyblok\\Bundle\\Tests\\Util\\TestKernel\:\:debug\(\) has parameter \$options with no value type specified in iterable type array\.$#'
47+
identifier: missingType.iterableValue
2548
count: 1
2649
path: tests/Util/TestKernel.php
2750

2851
-
29-
message: "#^Method Storyblok\\\\Bundle\\\\Tests\\\\Util\\\\TestKernel\\:\\:environment\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
52+
message: '#^Method Storyblok\\Bundle\\Tests\\Util\\TestKernel\:\:environment\(\) has parameter \$options with no value type specified in iterable type array\.$#'
53+
identifier: missingType.iterableValue
3054
count: 1
3155
path: tests/Util/TestKernel.php

src/Block/Attribute/AsBlock.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of sensiolabs-de/storyblok-bundle.
7+
*
8+
* (c) SensioLabs Deutschland <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Storyblok\Bundle\Block\Attribute;
15+
16+
#[\Attribute(\Attribute::TARGET_CLASS)]
17+
final readonly class AsBlock
18+
{
19+
public function __construct(
20+
public ?string $name = null,
21+
public ?string $template = null,
22+
) {
23+
}
24+
}

src/Block/BlockDefinition.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of sensiolabs-de/storyblok-bundle.
7+
*
8+
* (c) SensioLabs Deutschland <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Storyblok\Bundle\Block;
15+
16+
use Webmozart\Assert\Assert;
17+
18+
final readonly class BlockDefinition
19+
{
20+
/**
21+
* @param class-string $className
22+
*/
23+
public function __construct(
24+
public string $name,
25+
public string $className,
26+
public string $template,
27+
) {
28+
Assert::stringNotEmpty($name);
29+
Assert::notWhitespaceOnly($name);
30+
31+
Assert::stringNotEmpty($className);
32+
Assert::notWhitespaceOnly($className);
33+
Assert::classExists($className);
34+
35+
Assert::stringNotEmpty($template);
36+
Assert::notWhitespaceOnly($template);
37+
}
38+
39+
/**
40+
* @param array<string, mixed> $values
41+
*/
42+
public static function fromArray(array $values): self
43+
{
44+
Assert::keyExists($values, 'className');
45+
Assert::string($values['className']);
46+
Assert::classExists($values['className']);
47+
48+
Assert::keyExists($values, 'name');
49+
Assert::string($values['name']);
50+
51+
Assert::keyExists($values, 'template');
52+
Assert::string($values['template']);
53+
54+
return new self(
55+
name: $values['name'],
56+
className: $values['className'],
57+
template: $values['template'],
58+
);
59+
}
60+
}

src/Block/BlockRegistry.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of sensiolabs-de/storyblok-bundle.
7+
*
8+
* (c) SensioLabs Deutschland <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Storyblok\Bundle\Block;
15+
16+
use Storyblok\Bundle\Block\Exception\BlockNotFoundException;
17+
18+
final class BlockRegistry implements \Countable
19+
{
20+
/**
21+
* @var array<class-string, BlockDefinition>
22+
*/
23+
public static array $blocks = [];
24+
25+
public function __construct()
26+
{
27+
// Noop! Only used by the dependency injection. Static methods are used to interact with the registry.
28+
}
29+
30+
/**
31+
* @param array<string, mixed>|BlockDefinition $definition
32+
*/
33+
public static function add(array|BlockDefinition $definition): void
34+
{
35+
if (\is_array($definition)) {
36+
$definition = BlockDefinition::fromArray($definition);
37+
}
38+
39+
self::$blocks[$definition->className] = $definition;
40+
}
41+
42+
/**
43+
* @param class-string $fqcn
44+
*/
45+
public static function get(string $fqcn): BlockDefinition
46+
{
47+
if (!\array_key_exists($fqcn, self::$blocks)) {
48+
throw new BlockNotFoundException(\sprintf('Block "%s" not found.', $fqcn));
49+
}
50+
51+
return self::$blocks[$fqcn];
52+
}
53+
54+
public static function byName(string $name): BlockDefinition
55+
{
56+
$definitions = \array_values(\array_filter(
57+
self::$blocks,
58+
static fn (BlockDefinition $definition) => $definition->name === $name,
59+
));
60+
61+
if (0 === \count($definitions)) {
62+
throw new BlockNotFoundException(\sprintf('Block "%s" not found.', $name));
63+
}
64+
65+
return $definitions[0];
66+
}
67+
68+
public function count(): int
69+
{
70+
return \count(self::$blocks);
71+
}
72+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of sensiolabs-de/storyblok-bundle.
7+
*
8+
* (c) SensioLabs Deutschland <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Storyblok\Bundle\Block\Exception;
15+
16+
final class BlockNotFoundException extends \RuntimeException
17+
{
18+
}

src/Block/Renderer/BlockRenderer.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of sensiolabs-de/storyblok-bundle.
7+
*
8+
* (c) SensioLabs Deutschland <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Storyblok\Bundle\Block\Renderer;
15+
16+
use Storyblok\Bundle\Block\BlockRegistry;
17+
use Storyblok\Bundle\Block\Exception\BlockNotFoundException;
18+
use Twig\Environment;
19+
use Webmozart\Assert\Assert;
20+
21+
final readonly class BlockRenderer implements RendererInterface
22+
{
23+
public function __construct(
24+
private Environment $twig,
25+
private BlockRegistry $blocks,
26+
) {
27+
}
28+
29+
public function render(array|object $values): string
30+
{
31+
try {
32+
if (\is_object($values)) {
33+
$definition = $this->blocks::get($values::class);
34+
35+
$block = $values;
36+
} else {
37+
Assert::keyExists($values, 'component');
38+
$name = $values['component'];
39+
$definition = $this->blocks::byName($name);
40+
41+
$block = new ($definition->className)($values);
42+
}
43+
44+
return $this->twig->render($definition->template, ['block' => $block]);
45+
} catch (BlockNotFoundException) {
46+
return '';
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)