From 1862f138db88366db718cbe608cc4a718e05e51f Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 27 May 2026 18:28:55 +0200 Subject: [PATCH 1/4] Remove dangling `

` tags inside shortcodes with text content --- README.md | 11 +++++++ .../Compiler/ShortcodeCompilerPass.php | 10 ++++++ src/DependencyInjection/Configuration.php | 4 +++ .../WebfactoryShortcodeExtension.php | 7 ++++ ...aphElementsInsideShortcodeEventHandler.php | 32 +++++++++++++++++++ src/Resources/config/shortcodes.php | 13 ++++---- 6 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 src/Handler/RemovePendingParagraphElementsInsideShortcodeEventHandler.php diff --git a/README.md b/README.md index 1514718..92e0f1d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ services: - { name: 'webfactory.shortcode', shortcode: 'my-shortcode-name' } ``` +### Registering Event Listeners + +The [thunderer/Shortcode](https://github.com/thunderer/Shortcode) package uses an `EventContainer` to dispatch events during shortcode processing. You can register your own event listeners by tagging services with `webfactory.shortcode.event_listener` and specifying the `event` attribute: + +```yaml +services: + My\Shortcode\EventListener: + tags: + - { name: 'webfactory.shortcode.event_listener', event: 'event.replace-shortcodes' } +``` + ### Removing `

` Tags around Shortcodes By default, the `RemoveWrappingParagraphElementsEventHandler` contained in this bundle will be used to remove `

...

` tags around shortcodes, if the shortcode is the only text content in that paragraph. diff --git a/src/DependencyInjection/Compiler/ShortcodeCompilerPass.php b/src/DependencyInjection/Compiler/ShortcodeCompilerPass.php index 43d1f70..9f53fed 100644 --- a/src/DependencyInjection/Compiler/ShortcodeCompilerPass.php +++ b/src/DependencyInjection/Compiler/ShortcodeCompilerPass.php @@ -5,6 +5,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Thunder\Shortcode\EventContainer\EventContainer; use Thunder\Shortcode\HandlerContainer\HandlerContainer; use Webfactory\ShortcodeBundle\Controller\GuideController; @@ -25,6 +26,15 @@ public function process(ContainerBuilder $container): void } } + // add services tagged with webfactory.shortcode.event_listener as listeners to the event container + $eventListenerServices = $container->findTaggedServiceIds('webfactory.shortcode.event_listener'); + $eventContainer = $container->findDefinition(EventContainer::class); + foreach ($eventListenerServices as $id => $tags) { + foreach ($tags as $tag) { + $eventContainer->addMethodCall('addListener', [$tag['event'], new Reference($id)]); + } + } + // prepare the GuideController if it's configuration is imported if ($container->has(GuideController::class)) { $allShortcodeTags = []; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 58365ab..8385acb 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -29,6 +29,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->info('Limit the number of iterations when resolving shortcodes') ->defaultValue(null) ->end() + ->booleanNode('remove_pending_inner_paragraph_elements') + ->info('Remove

...

inside shortcodes with text content (using opening and closing shortcode markup)') + ->defaultFalse() + ->end() ->arrayNode('shortcodes') ->normalizeKeys(false) ->arrayPrototype() diff --git a/src/DependencyInjection/WebfactoryShortcodeExtension.php b/src/DependencyInjection/WebfactoryShortcodeExtension.php index 685c114..65df0d5 100644 --- a/src/DependencyInjection/WebfactoryShortcodeExtension.php +++ b/src/DependencyInjection/WebfactoryShortcodeExtension.php @@ -7,6 +7,8 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Thunder\Shortcode\Events; +use Webfactory\ShortcodeBundle\Handler\RemovePendingParagraphElementsInsideShortcodeEventHandler; /** * Loads the bundle configuration. @@ -27,6 +29,11 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('webfactory_shortcode.recursion_depth', $config['recursion_depth']); $container->setParameter('webfactory_shortcode.max_iterations', $config['max_iterations']); + if ($config['remove_pending_inner_paragraph_elements']) { + $container->getDefinition(RemovePendingParagraphElementsInsideShortcodeEventHandler::class) + ->clearTag('webfactory.shortcode.event_listener'); + } + foreach ($config['shortcodes'] as $shortcodeName => $shortcodeDefinition) { $definition = new ChildDefinition('Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.'.$shortcodeDefinition['method']); $definition->replaceArgument(1, $shortcodeDefinition['controller']); diff --git a/src/Handler/RemovePendingParagraphElementsInsideShortcodeEventHandler.php b/src/Handler/RemovePendingParagraphElementsInsideShortcodeEventHandler.php new file mode 100644 index 0000000..b2eb8f1 --- /dev/null +++ b/src/Handler/RemovePendingParagraphElementsInsideShortcodeEventHandler.php @@ -0,0 +1,32 @@ +...

" directly inside a shortcode. + * + * This may happen when using shortcodes with content like the following, where the outer "

...

" will be removed + * by `RemoveWrappingParagraphElementsEventHandler`. + * + *

[shortcode]

Inner content

[/shortcode]

+ */ +final class RemovePendingParagraphElementsInsideShortcodeEventHandler +{ + public function __invoke(ReplaceShortcodesEvent $event): void + { + if (!$event->getShortcode()) { + return; + } + + $text = $event->getText(); + + if ( + preg_match('~^\s*

\s*~', $text, $prefixMatch) + && preg_match('~\s*

\s*$~', $text, $postfixMatch) + ) { + $event->setResult(mb_substr($text, mb_strlen($prefixMatch[0], 'utf-8'), -mb_strlen($postfixMatch[0], 'utf-8'))); + } + } +} diff --git a/src/Resources/config/shortcodes.php b/src/Resources/config/shortcodes.php index 2f80088..c463638 100644 --- a/src/Resources/config/shortcodes.php +++ b/src/Resources/config/shortcodes.php @@ -2,7 +2,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use function Symfony\Component\DependencyInjection\Loader\Configurator\inline_service; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return static function(ContainerConfigurator $container) { @@ -31,11 +30,13 @@ ->public() ->factory([service(\Webfactory\ShortcodeBundle\Factory\ProcessorFactory::class), 'create']); - $services->set(\Thunder\Shortcode\EventContainer\EventContainer::class) - ->call('addListener', [ - \Thunder\Shortcode\Events::REPLACE_SHORTCODES, - inline_service(\Webfactory\ShortcodeBundle\Handler\RemoveWrappingParagraphElementsEventHandler::class), - ]); + $services->set(\Thunder\Shortcode\EventContainer\EventContainer::class); + + $services->set(\Webfactory\ShortcodeBundle\Handler\RemoveWrappingParagraphElementsEventHandler::class) + ->tag('webfactory.shortcode.event_listener', ['event' => \Thunder\Shortcode\Events::REPLACE_SHORTCODES]); + + $services->set(\Webfactory\ShortcodeBundle\Handler\RemovePendingParagraphElementsInsideShortcodeEventHandler::class) + ->tag('webfactory.shortcode.event_listener', ['event' => \Thunder\Shortcode\Events::REPLACE_SHORTCODES]); $services->set('Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler.esi', \Webfactory\ShortcodeBundle\Handler\EmbeddedShortcodeHandler::class) ->abstract() From 8b161eb0add4a7779236fdebb978ec28c2ea0436 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 27 May 2026 18:40:29 +0200 Subject: [PATCH 2/4] Add a test --- src/DependencyInjection/WebfactoryShortcodeExtension.php | 2 +- tests/Fixtures/TestKernel.php | 1 + tests/Functional/EndToEndTest.php | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/DependencyInjection/WebfactoryShortcodeExtension.php b/src/DependencyInjection/WebfactoryShortcodeExtension.php index 65df0d5..2c1511e 100644 --- a/src/DependencyInjection/WebfactoryShortcodeExtension.php +++ b/src/DependencyInjection/WebfactoryShortcodeExtension.php @@ -29,7 +29,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('webfactory_shortcode.recursion_depth', $config['recursion_depth']); $container->setParameter('webfactory_shortcode.max_iterations', $config['max_iterations']); - if ($config['remove_pending_inner_paragraph_elements']) { + if (! $config['remove_pending_inner_paragraph_elements']) { $container->getDefinition(RemovePendingParagraphElementsInsideShortcodeEventHandler::class) ->clearTag('webfactory.shortcode.event_listener'); } diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php index 4382d08..8fb86a2 100644 --- a/tests/Fixtures/TestKernel.php +++ b/tests/Fixtures/TestKernel.php @@ -42,6 +42,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void $testControllerAction = ShortcodeTestController::class.'::test'; $container->loadFromExtension('webfactory_shortcode', [ + 'remove_pending_inner_paragraph_elements' => true, 'shortcodes' => [ 'test-config-inline' => $testControllerAction, 'test-config-esi' => [ diff --git a/tests/Functional/EndToEndTest.php b/tests/Functional/EndToEndTest.php index 947280d..42552c9 100644 --- a/tests/Functional/EndToEndTest.php +++ b/tests/Functional/EndToEndTest.php @@ -24,6 +24,15 @@ public function paragraphs_wrapping_shortcodes_get_removed(): void ); } + #[Test] + public function paragraphs_inside_shortcode_with_content_get_removed(): void + { + $this->assertEquals( + '

the answer is 42

', + $this->renderTwig("{{ '

[placeholder value=42]

the answer is %value%

[/placeholder]

' | shortcodes }}") + ); + } + #[Test] public function content_without_shortcodes_wont_be_changed(): void { From 0a49f019befc54f3e0bdcf7415c94c749d544748 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 27 May 2026 18:52:10 +0200 Subject: [PATCH 3/4] Avoid mocking Symfony classes in functional tests --- .../Compiler/ShortcodeCompilerPassTest.php | 136 +++++++----------- 1 file changed, 49 insertions(+), 87 deletions(-) diff --git a/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php b/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php index 1ce6046..cd3f2d1 100644 --- a/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php +++ b/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php @@ -3,132 +3,94 @@ namespace Webfactory\ShortcodeBundle\Tests\DependencyInjection\Compiler; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; +use Thunder\Shortcode\EventContainer\EventContainer; use Thunder\Shortcode\HandlerContainer\HandlerContainer; use Webfactory\ShortcodeBundle\Controller\GuideController; use Webfactory\ShortcodeBundle\DependencyInjection\Compiler\ShortcodeCompilerPass; final class ShortcodeCompilerPassTest extends TestCase { - /** - * System under test. - * - * @var ShortcodeCompilerPass - */ - private $compilerPass; - - /** @var ContainerBuilder|MockObject */ - private $containerBuilder; + private ShortcodeCompilerPass $compilerPass; + private ContainerBuilder $containerBuilder; protected function setUp(): void { $this->compilerPass = new ShortcodeCompilerPass(); - $this->containerBuilder = $this->getMockBuilder(ContainerBuilder::class) - ->disableOriginalConstructor() - ->getMock(); + $this->containerBuilder = new ContainerBuilder(); + + // Register the service definitions that the compiler pass always looks up + $this->containerBuilder->setDefinition(HandlerContainer::class, new Definition(HandlerContainer::class)); + $this->containerBuilder->setDefinition(EventContainer::class, new Definition(EventContainer::class)); } #[Test] public function tagged_services_are_added_as_handlers_to_handler_container(): void { - $this->containerBuilder->expects($this->once()) - ->method('findTaggedServiceIds') - ->willReturn([ - 'service_id1' => [ - ['shortcode' => 'shortcode1'], - ], - 'service_id2' => [ - ['shortcode' => 'shortcode2'], - ], - ]); - - $mockedShortcodeHandlerContainer = $this->createMock(Definition::class); - $mockedShortcodeHandlerContainer->expects($this->exactly(2)) - ->method('addMethodCall') - ->with('add', $this->callback(function (array $argument) { - static $count = 0; - ++$count; - - return 'shortcode'.$count === $argument[0] && $argument[1] instanceof Reference; - })); - - $this->containerBuilder->expects($this->once()) - ->method('findDefinition') - ->with(HandlerContainer::class) - ->willReturn($mockedShortcodeHandlerContainer); + $this->containerBuilder->register('service_id1', \stdClass::class) + ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode1']); + $this->containerBuilder->register('service_id2', \stdClass::class) + ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode2']); $this->compilerPass->process($this->containerBuilder); + + $methodCalls = $this->containerBuilder->getDefinition(HandlerContainer::class)->getMethodCalls(); + + $this->assertCount(2, $methodCalls); + + [$method1, $args1] = $methodCalls[0]; + $this->assertSame('add', $method1); + $this->assertSame('shortcode1', $args1[0]); + $this->assertInstanceOf(Reference::class, $args1[1]); + $this->assertSame('service_id1', (string) $args1[1]); + + [$method2, $args2] = $methodCalls[1]; + $this->assertSame('add', $method2); + $this->assertSame('shortcode2', $args2[0]); + $this->assertInstanceOf(Reference::class, $args2[1]); + $this->assertSame('service_id2', (string) $args2[1]); } #[Test] public function no_tagged_services_do_no_harm(): void { - $this->containerBuilder->expects($this->once()) - ->method('findTaggedServiceIds') - ->willReturn([]); - $this->compilerPass->process($this->containerBuilder); + + $this->assertCount( + 0, + $this->containerBuilder->getDefinition(HandlerContainer::class)->getMethodCalls() + ); } #[Test] public function shortcode_guide_service_gets_configured_if_set(): void { - $this->containerBuilder->expects($this->once()) - ->method('findTaggedServiceIds') - ->willReturn([ - 'service_id1' => [ - ['shortcode' => 'shortcode1'], - ], - 'service_id2' => [ - ['shortcode' => 'shortcode2'], - ], - ]); - - $this->containerBuilder->expects($this->once()) - ->method('findDefinition') - ->with(HandlerContainer::class) - ->willReturn($this->createMock(Definition::class)); - - $this->containerBuilder->expects($this->once()) - ->method('has') - ->with(GuideController::class) - ->willReturn(true); - - $mockedShortcodeGuideServiceDefinition = $this->createMock(Definition::class); - $mockedShortcodeGuideServiceDefinition->expects($this->once()) - ->method('setArgument') - ->with( - 0, - [ - ['shortcode' => 'shortcode1'], - ['shortcode' => 'shortcode2'], - ] - ); - - $this->containerBuilder->expects($this->once()) - ->method('getDefinition') - ->with(GuideController::class) - ->willReturn($mockedShortcodeGuideServiceDefinition); + $this->containerBuilder->register('service_id1', \stdClass::class) + ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode1']); + $this->containerBuilder->register('service_id2', \stdClass::class) + ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode2']); + + $this->containerBuilder->setDefinition(GuideController::class, new Definition(GuideController::class)); $this->compilerPass->process($this->containerBuilder); + + $this->assertSame( + [ + ['shortcode' => 'shortcode1'], + ['shortcode' => 'shortcode2'], + ], + $this->containerBuilder->getDefinition(GuideController::class)->getArgument(0) + ); } #[Test] public function missing_shortcode_guide_service_does_no_harm(): void { - $this->containerBuilder->expects($this->once()) - ->method('findTaggedServiceIds') - ->willReturn([]); - - $this->containerBuilder->expects($this->once()) - ->method('has') - ->with(GuideController::class) - ->willReturn(false); - $this->compilerPass->process($this->containerBuilder); + + $this->assertFalse($this->containerBuilder->has(GuideController::class)); } } From b8349d1f1419209b620d26f47f840c6cd79c01ec Mon Sep 17 00:00:00 2001 From: mpdude <1202333+mpdude@users.noreply.github.com> Date: Wed, 27 May 2026 16:52:40 +0000 Subject: [PATCH 4/4] Fix CS with PHP-CS-Fixer --- src/DependencyInjection/WebfactoryShortcodeExtension.php | 3 +-- .../Compiler/ShortcodeCompilerPassTest.php | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/DependencyInjection/WebfactoryShortcodeExtension.php b/src/DependencyInjection/WebfactoryShortcodeExtension.php index 2c1511e..12f9d56 100644 --- a/src/DependencyInjection/WebfactoryShortcodeExtension.php +++ b/src/DependencyInjection/WebfactoryShortcodeExtension.php @@ -7,7 +7,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; -use Thunder\Shortcode\Events; use Webfactory\ShortcodeBundle\Handler\RemovePendingParagraphElementsInsideShortcodeEventHandler; /** @@ -29,7 +28,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('webfactory_shortcode.recursion_depth', $config['recursion_depth']); $container->setParameter('webfactory_shortcode.max_iterations', $config['max_iterations']); - if (! $config['remove_pending_inner_paragraph_elements']) { + if (!$config['remove_pending_inner_paragraph_elements']) { $container->getDefinition(RemovePendingParagraphElementsInsideShortcodeEventHandler::class) ->clearTag('webfactory.shortcode.event_listener'); } diff --git a/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php b/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php index cd3f2d1..0e6e679 100644 --- a/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php +++ b/tests/DependencyInjection/Compiler/ShortcodeCompilerPassTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use stdClass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; @@ -30,9 +31,9 @@ protected function setUp(): void #[Test] public function tagged_services_are_added_as_handlers_to_handler_container(): void { - $this->containerBuilder->register('service_id1', \stdClass::class) + $this->containerBuilder->register('service_id1', stdClass::class) ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode1']); - $this->containerBuilder->register('service_id2', \stdClass::class) + $this->containerBuilder->register('service_id2', stdClass::class) ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode2']); $this->compilerPass->process($this->containerBuilder); @@ -68,9 +69,9 @@ public function no_tagged_services_do_no_harm(): void #[Test] public function shortcode_guide_service_gets_configured_if_set(): void { - $this->containerBuilder->register('service_id1', \stdClass::class) + $this->containerBuilder->register('service_id1', stdClass::class) ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode1']); - $this->containerBuilder->register('service_id2', \stdClass::class) + $this->containerBuilder->register('service_id2', stdClass::class) ->addTag('webfactory.shortcode', ['shortcode' => 'shortcode2']); $this->containerBuilder->setDefinition(GuideController::class, new Definition(GuideController::class));