diff --git a/Classes/Frontend/ContentObject/HandlebarsTemplateContentObject.php b/Classes/Frontend/ContentObject/HandlebarsTemplateContentObject.php
new file mode 100644
index 00000000..13c4a97b
--- /dev/null
+++ b/Classes/Frontend/ContentObject/HandlebarsTemplateContentObject.php
@@ -0,0 +1,169 @@
+
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\Frontend\ContentObject;
+
+use Fr\Typo3Handlebars\Renderer;
+use TYPO3\CMS\Core;
+use TYPO3\CMS\Frontend;
+
+/**
+ * HandlebarsTemplateContentObject
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+final class HandlebarsTemplateContentObject extends Frontend\ContentObject\FluidTemplateContentObject
+{
+ public function __construct(
+ Frontend\ContentObject\ContentDataProcessor $contentDataProcessor,
+ private readonly Renderer\Renderer $renderer,
+ private readonly Renderer\Template\Path\ContentObjectPathProvider $pathProvider,
+ private readonly Core\TypoScript\TypoScriptService $typoScriptService,
+ ) {
+ parent::__construct($contentDataProcessor);
+ }
+
+ /**
+ * @param array $conf
+ */
+ public function render($conf = []): string
+ {
+ if (!is_array($conf)) {
+ $conf = [];
+ }
+
+ // Create handlebars view
+ $view = $this->createView($conf);
+
+ // Populate template paths
+ $this->pathProvider->push(
+ array_intersect_key(
+ $this->typoScriptService->convertTypoScriptArrayToPlainArray($conf),
+ [
+ 'partialRootPath' => true,
+ 'partialRootPaths' => true,
+ 'templateRootPath' => true,
+ 'templateRootPaths' => true,
+ ],
+ ),
+ );
+
+ $view->assignMultiple($this->resolveVariables($conf));
+
+ $this->renderPageAssetsIntoPageRenderer($conf, $view);
+
+ try {
+ $content = $this->renderer->render($view);
+ } finally {
+ // Remove current content object rendering from path provider stack
+ $this->pathProvider->pop();
+ }
+
+ return $this->applyStandardWrapToRenderedContent($content, $conf);
+ }
+
+ /**
+ * @param array $config
+ */
+ private function createView(array $config): Renderer\Template\View\HandlebarsView
+ {
+ $format = $this->cObj?->stdWrapValue('format', $config, null);
+ $view = new Renderer\Template\View\HandlebarsView();
+
+ if (is_string($format)) {
+ $view->setFormat($format);
+ }
+
+ if (isset($config['templateName']) || isset($config['templateName.'])) {
+ return $view->setTemplatePath(
+ (string)$this->cObj?->stdWrapValue('templateName', $config),
+ );
+ }
+
+ if (isset($config['template']) || isset($config['template.'])) {
+ return $view->setTemplateSource(
+ (string)$this->cObj?->stdWrapValue('template', $config),
+ );
+ }
+
+ if (isset($config['file']) || isset($config['file.'])) {
+ return $view->setTemplatePath(
+ (string)$this->cObj?->stdWrapValue('file', $config),
+ );
+ }
+
+ return $view;
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private function resolveVariables(array $config): array
+ {
+ $variables = $this->getContentObjectVariables($config);
+
+ if ($this->cObj !== null) {
+ $variables = $this->contentDataProcessor->process($this->cObj, $config, $variables);
+ }
+
+ if (isset($config['settings.'])) {
+ $variables['settings'] = $this->typoScriptService->convertTypoScriptArrayToPlainArray($config['settings.']);
+ }
+
+ return $variables;
+ }
+
+ /**
+ * @param array $config
+ */
+ private function renderPageAssetsIntoPageRenderer(array $config, Renderer\Template\View\HandlebarsView $view): void
+ {
+ $headerAssets = $this->renderAssets($config['headerAssets.'] ?? [], $view);
+ $footerAssets = $this->renderAssets($config['footerAssets.'] ?? [], $view);
+
+ if (\trim($headerAssets) !== '') {
+ $this->getPageRenderer()->addHeaderData($headerAssets);
+ }
+
+ if (\trim($footerAssets) !== '') {
+ $this->getPageRenderer()->addFooterData($footerAssets);
+ }
+ }
+
+ /**
+ * @param array $config
+ */
+ private function renderAssets(array $config, Renderer\Template\View\HandlebarsView $baseView): string
+ {
+ if ($config === []) {
+ return '';
+ }
+
+ $view = $this->createView($config);
+ $view->assignMultiple($baseView->getVariables());
+
+ return $this->renderer->render($view);
+ }
+}
diff --git a/Classes/Renderer/Template/Path/ContentObjectPathProvider.php b/Classes/Renderer/Template/Path/ContentObjectPathProvider.php
new file mode 100644
index 00000000..daba921c
--- /dev/null
+++ b/Classes/Renderer/Template/Path/ContentObjectPathProvider.php
@@ -0,0 +1,157 @@
+
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\Renderer\Template\Path;
+
+use TYPO3\CMS\Core;
+
+/**
+ * ContentObjectPathProvider
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ *
+ * @phpstan-type RootPathStackItem array{
+ * current: array,
+ * merged: array|null,
+ * }
+ */
+final class ContentObjectPathProvider implements PathProvider, Core\SingletonInterface
+{
+ /**
+ * @var list
+ */
+ private array $stack = [];
+
+ private ?int $currentItem = null;
+
+ /**
+ * @param array $configuration
+ */
+ public function push(array $configuration): void
+ {
+ $partialRootPaths = [];
+ $templateRootPaths = [];
+
+ if (is_array($configuration['templateRootPaths'] ?? null)) {
+ $templateRootPaths = $configuration['templateRootPaths'];
+ }
+ if (is_array($configuration['partialRootPaths'] ?? null)) {
+ $partialRootPaths = $configuration['partialRootPaths'];
+ }
+
+ // A single root path receives highest priority
+ if (is_string($configuration['templateRootPath'] ?? null)) {
+ $templateRootPaths[PHP_INT_MAX] = $configuration['templateRootPath'];
+ }
+ if (is_string($configuration['partialRootPath'] ?? null)) {
+ $partialRootPaths[PHP_INT_MAX] = $configuration['partialRootPath'];
+ }
+
+ ksort($templateRootPaths);
+ ksort($partialRootPaths);
+
+ $this->stack[] = [
+ 'partialRootPaths' => [
+ 'current' => $partialRootPaths,
+ 'merged' => null,
+ ],
+ 'templateRootPaths' => [
+ 'current' => $templateRootPaths,
+ 'merged' => null,
+ ],
+ ];
+
+ if ($this->currentItem === null) {
+ $this->currentItem = 0;
+ } else {
+ $this->currentItem++;
+ }
+ }
+
+ public function pop(): void
+ {
+ if ($this->stack === []) {
+ return;
+ }
+
+ array_pop($this->stack);
+
+ if ($this->stack === []) {
+ $this->currentItem = null;
+ } else {
+ $this->currentItem--;
+ }
+ }
+
+ public function getPartialRootPaths(): array
+ {
+ return $this->getMergedRootPathsFromStack('partialRootPaths');
+ }
+
+ public function getTemplateRootPaths(): array
+ {
+ return $this->getMergedRootPathsFromStack('templateRootPaths');
+ }
+
+ public function isCacheable(): bool
+ {
+ // Caching is done internally, based on the current stack
+ return false;
+ }
+
+ /**
+ * @param 'partialRootPaths'|'templateRootPaths' $type
+ * @return array
+ */
+ private function getMergedRootPathsFromStack(string $type): array
+ {
+ if ($this->currentItem === null) {
+ return [];
+ }
+
+ // Merge and cache root paths
+ if ($this->stack[$this->currentItem][$type]['merged'] === null) {
+ /* @phpstan-ignore offsetAccess.notFound */
+ $this->stack[$this->currentItem][$type]['merged'] = $this->stack[$this->currentItem][0]['current'];
+
+ for ($i = 1; $i < $this->currentItem; $i++) {
+ Core\Utility\ArrayUtility::mergeRecursiveWithOverrule(
+ $this->stack[$this->currentItem][$type]['merged'],
+ $this->stack[$i][$type]['current'],
+ );
+ }
+ }
+
+ /* @phpstan-ignore return.type */
+ return $this->stack[$this->currentItem][$type]['merged'];
+ }
+
+ public static function getPriority(): int
+ {
+ return 100;
+ }
+}
diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml
index 45bfb265..c3bd3dbc 100644
--- a/Configuration/Services.yaml
+++ b/Configuration/Services.yaml
@@ -16,6 +16,12 @@ services:
Fr\Typo3Handlebars\Renderer\Template\TemplateResolver:
alias: 'handlebars.template_resolver'
+ # Content object
+ Fr\Typo3Handlebars\Frontend\ContentObject\HandlebarsTemplateContentObject:
+ tags:
+ - name: frontend.contentobject
+ identifier: 'HANDLEBARSTEMPLATE'
+
# Template
handlebars.template_resolver:
class: 'Fr\Typo3Handlebars\Renderer\Template\HandlebarsTemplateResolver'