diff --git a/src/View/Antlers/Language/Parser/ComponentCompiler.php b/src/View/Antlers/Language/Parser/ComponentCompiler.php
new file mode 100644
index 00000000000..d692e425f82
--- /dev/null
+++ b/src/View/Antlers/Language/Parser/ComponentCompiler.php
@@ -0,0 +1,59 @@
+registerCustomComponentTags($this->statamicTags)
+ ->onlyParseComponents()
+ ->parseTemplate($template)
+ ->toDocument()
+ ->getRootNodes()
+ ->pipe(fn ($nodes) => $this->compileNodes($nodes));
+ }
+
+ protected function compileNodes($nodes)
+ {
+ return $nodes
+ ->map(function ($node) {
+ if (! $node instanceof ComponentNode) {
+ return $node->unescapedContent;
+ }
+
+ if (! in_array(mb_strtolower($node->componentPrefix), $this->statamicTags)) {
+ return $node->outerDocumentContent;
+ }
+
+ if ($node->isClosingTag && ! $node->isSelfClosing) {
+ return '';
+ }
+
+ return $this->compileComponent($node);
+ })
+ ->join('');
+ }
+
+ protected function compileComponent(ComponentNode $component)
+ {
+ if ($component->isSelfClosing) {
+ return "{{ %$component->innerContent /}}";
+ }
+
+ $innerContent = $this->compileNodes($component->getNodes());
+
+ return "{{ %$component->innerContent }}$innerContent{{ /%$component->innerContent }}";
+ }
+}
diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php
index 4283493d4cf..a6902abe7e5 100644
--- a/src/View/Antlers/Language/Runtime/RuntimeParser.php
+++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php
@@ -23,6 +23,7 @@
use Statamic\View\Antlers\Language\Nodes\AbstractNode;
use Statamic\View\Antlers\Language\Nodes\AntlersNode;
use Statamic\View\Antlers\Language\Nodes\Position;
+use Statamic\View\Antlers\Language\Parser\ComponentCompiler;
use Statamic\View\Antlers\Language\Parser\DocumentParser;
use Statamic\View\Antlers\Language\Parser\LanguageKeywords;
use Statamic\View\Antlers\Language\Parser\LanguageParser;
@@ -117,12 +118,16 @@ class RuntimeParser implements Parser
*/
private $isolateRuntimes = false;
+ protected $componentCompiler;
+
public function __construct(DocumentParser $documentParser, NodeProcessor $nodeProcessor, AntlersLexer $lexer, LanguageParser $antlersParser)
{
$this->documentParser = $documentParser;
$this->nodeProcessor = $nodeProcessor;
$this->antlersLexer = $lexer;
$this->antlersParser = $antlersParser;
+
+ $this->componentCompiler = new ComponentCompiler();
}
/**
@@ -328,6 +333,8 @@ protected function isIgnitionInstalled()
*/
protected function renderText($text, $data = [])
{
+ $text = $this->componentCompiler->compile($text);
+
$this->parseStack += 1;
$text = $this->runPreParserCallbacks($text);
diff --git a/tests/Antlers/Parser/ComponentTagsTest.php b/tests/Antlers/Parser/ComponentTagsTest.php
new file mode 100644
index 00000000000..0d1721e4e2d
--- /dev/null
+++ b/tests/Antlers/Parser/ComponentTagsTest.php
@@ -0,0 +1,136 @@
+routes(['en' => '{slug}'])
+ ->save();
+
+ for ($i = 1; $i <= 5; $i++) {
+ EntryFactory::collection('pages')
+ ->id("page-$i")
+ ->data([
+ 'title' => "Page: $i",
+ ])
+ ->slug('page-'.$i)
+ ->create();
+ }
+ }
+
+ public function test_component_tags_are_parsed()
+ {
+ $template = <<<'ANTLERS'
+
+ {{ title }}.
+
+ANTLERS;
+
+ $this->assertSame(
+ 'Page: 1. Page: 2. Page: 3. Page: 4. Page: 5.',
+ (string) str($this->renderString($template))->squish(),
+ );
+ }
+
+ public function test_component_tags_with_dynamic_parameters_are_parsed()
+ {
+ $template = <<<'ANTLERS'
+{{ title }}.
+ANTLERS;
+
+ $result = $this->renderString($template, [
+ 'collection_name' => 'pages',
+ ]);
+
+ $this->assertSame(
+ 'Page: 1.Page: 2.Page: 3.Page: 4.Page: 5.',
+ $result,
+ );
+ }
+
+ public function test_component_tags_with_parameters_are_parsed()
+ {
+ $template = <<<'ANTLERS'
+{{ title }}
+ANTLERS;
+
+ $this->assertSame(
+ 'Page: 1',
+ $this->renderString($template),
+ );
+ }
+
+ public function test_self_closing_component_tags_are_parsed()
+ {
+ (new class extends Tags
+ {
+ protected static $handle = 'the_tag';
+
+ public function index()
+ {
+ if ($this->isPair) {
+ return 'Paired!';
+ }
+
+ return 'Self-Closing!';
+ }
+ })::register();
+
+ $this->assertSame('Self-Closing!', $this->renderString(''));
+ $this->assertSame('Paired!', $this->renderString(' '));
+ }
+
+ public function test_nested_component_tags_are_parsed()
+ {
+ $template = <<<'ANTLERS'
+
+ Before: {{ title }}
+
+ {{ title }}
+
+ After: {{ title }}
+
+ANTLERS;
+
+ $expected = <<<'RESULT'
+Before: Page: 1 Page: 5Page: 4 After: Page: 1 Before: Page: 2 Page: 5Page: 4 After: Page: 2 Before: Page: 3 Page: 5Page: 4 After: Page: 3 Before: Page: 4 Page: 5Page: 4 After: Page: 4 Before: Page: 5 Page: 5Page: 4 After: Page: 5
+RESULT;
+
+ $this->assertSame(
+ $expected,
+ (string) str($this->renderString($template))->squish(),
+ );
+ }
+
+ public function test_component_syntax_works_with_render_text_calls()
+ {
+ $this->withFakeViews();
+ $template = <<<'ANTLERS'
+Page Title: {{ title }}
+{{ title }}.
+ANTLERS;
+
+ $this->viewShouldReturnRaw('default', $template);
+ $this->viewShouldReturnRaw('layout', '{{ template_content }}');
+
+ $this->get('/page-1')
+ ->assertOk()
+ ->assertSee('Page Title: Page: 1')
+ ->assertSee('Page: 1.Page: 2.Page: 3.Page: 4.Page: 5.');
+ }
+}