tag (depending on the DocType) and possible translation files. */ protected Locale $locale; // Arrays containing associative arrays for the included files /** * @var array */ protected array $jsFiles = []; protected array $jsLibs = []; /** * @var array */ protected array $cssFiles = []; /** * @var array */ protected array $cssLibs = []; protected string $title = ''; protected string $favIcon = ''; // Static header blocks protected string $xmlPrologAndDocType = ''; protected array $inlineComments = []; protected array $headerData = []; protected array $footerData = []; protected string $titleTag = '|'; protected string $htmlTag = ''; protected string $headTag = ''; protected string $iconMimeType = ''; protected string $shortcutTag = ''; // Static inline code blocks /** * @var array */ protected array $jsInline = []; /** * @var array */ protected array $cssInline = []; protected string $bodyContent = ''; protected string $templateFile = 'PKG:typo3/cms-core:Resources/Private/Templates/PageRenderer.html'; protected array $inlineLanguageLabels = []; protected array $inlineLanguageLabelFiles = []; protected array $inlineSettings = []; /** * Is empty string for HTML and ' /' for XHTML rendering */ protected string $endingSlash = ''; protected JavaScriptRenderer $javaScriptRenderer; protected ?ConsumableNonce $nonce = null; protected DocType $docType = DocType::html5; protected bool $applyNonceHint = false; public function __construct( protected readonly Context $context, #[Autowire(service: 'cache.assets')] protected readonly FrontendInterface $assetsCache, protected readonly MarkerBasedTemplateService $templateService, protected readonly MetaTagManagerRegistry $metaTagRegistry, protected readonly AssetRenderer $assetRenderer, protected readonly AssetCollector $assetCollector, protected readonly RelativeCssPathFixer $relativeCssPathFixer, protected readonly LanguageServiceFactory $languageServiceFactory, protected readonly ResponseFactoryInterface $responseFactory, protected readonly StreamFactoryInterface $streamFactory, protected readonly IconRegistry $iconRegistry, protected readonly SystemResourcePublisherInterface $resourcePublisher, protected readonly SystemResourceFactory $systemResourceFactory, protected readonly ResourceHashCollection $resourceHashCollection, protected readonly DirectiveHashCollection $directiveHashCollection, ) { $this->locale = new Locale(); $this->docType = DocType::html5; $this->xmlPrologAndDocType = DocType::html5->getDoctypeDeclaration(); $htmlTagAttributes = ['lang' => 'en']; $backendUserAspect = $this->context->getAspect('backend.user'); if ($backendUserAspect->isLoggedIn()) { // If a backend user is logged in, we assume BE context and add a default html tag // with theme and color scheme attributes for this backend user. // In case this is FE context, FE RequestHandler will later override with final html tag attributes again. // This is done here for BE b/w compat reasons. Assuming BE context is done to prevent // accessing Request in __construct() and application type is only available as Request attribute. $themeAndColorSchemeAttributes = $this->getThemeAndColorSchemeHtmlTagAttributes($this->getBackendUser()); $htmlTagAttributes = array_merge($htmlTagAttributes, $themeAndColorSchemeAttributes); } $this->htmlTag = ''; $this->javaScriptRenderer = JavaScriptRenderer::create('EXT:core/Resources/Public/JavaScript/java-script-item-handler.js'); $this->setMetaTag('name', 'generator', 'TYPO3 CMS'); } /** * @internal */ public function updateState(array $state): void { foreach ($state as $var => $value) { switch ($var) { case 'assetsCache': case 'assetRenderer': case 'assetCollector': case 'context': case 'templateService': case 'relativeCssPathFixer': case 'languageServiceFactory': case 'responseFactory': case 'streamFactory': case 'iconRegistry': case 'resourcePublisher': case 'systemResourceFactory': case 'resourceHashCollection': case 'directiveHashCollection': case 'nonce': break; case 'metaTagRegistry': $this->metaTagRegistry->updateState($value); break; case 'javaScriptRenderer': $this->javaScriptRenderer->updateState($value); break; default: $this->{$var} = $value; break; } } } /** * @internal */ public function getState(): array { $state = []; foreach (get_object_vars($this) as $var => $value) { switch ($var) { case 'assetsCache': case 'assetRenderer': case 'context': case 'templateService': case 'relativeCssPathFixer': case 'languageServiceFactory': case 'responseFactory': case 'streamFactory': case 'iconRegistry': case 'resourcePublisher': case 'nonce': case 'systemResourceFactory': case 'resourceHashCollection': case 'directiveHashCollection': break; case 'metaTagRegistry': $state[$var] = $this->metaTagRegistry->getState(); break; case 'javaScriptRenderer': $state[$var] = $this->javaScriptRenderer->getState(); break; default: $state[$var] = $value; break; } } return $state; } /** * BE only, FE uses a different approach to register JS */ public function getJavaScriptRenderer(): JavaScriptRenderer { return $this->javaScriptRenderer; } /** * Content of tag in <html><head> */ public function setTitle(string $title): void { $this->title = $title; } /** * Sets xml prolog and docType. * FE only, BE is hard coded html5. * * @param string $xmlPrologAndDocType Complete tags for xml prolog and docType */ public function setXmlPrologAndDocType(string $xmlPrologAndDocType): void { $this->xmlPrologAndDocType = $xmlPrologAndDocType; } /** * Sets language */ public function setLanguage(Locale $locale, ServerRequestInterface $request): void { $this->locale = $locale; $this->setDefaultHtmlTag($request); } /** * Sets html tag * FE only, BE is hard coded html5. */ public function setHtmlTag(string $htmlTag): void { $this->htmlTag = $htmlTag; } /** * Sets HTML head tag * FE only. */ public function setHeadTag(string $headTag): void { $this->headTag = $headTag; } /** * Sets favicon */ public function setFavIcon(string $favIcon): void { $this->favIcon = $favIcon; } /** * Sets icon mime type */ public function setIconMimeType(string $iconMimeType): void { $this->iconMimeType = $iconMimeType; } /** * Sets template file * FE only. */ public function setTemplateFile(string $file): void { $this->templateFile = $file; } /** * Sets Content for Body * BE only, FE uses addBodyContent */ public function setBodyContent(string $content): void { $this->bodyContent = $content; } /** * BE only? */ public function setApplyNonceHint(bool $applyNonceHint): void { $this->applyNonceHint = $applyNonceHint; } /** * FE only */ public function enableMoveJsFromHeaderToFooter(): void { $this->moveJsFromHeaderToFooter = true; } /** * FE only, unused. */ public function disableMoveJsFromHeaderToFooter(): void { $this->moveJsFromHeaderToFooter = false; } public function setNonce(?ConsumableNonce $nonce): void { $this->nonce = $nonce; } /** * FE only, BE is hard coded HTML5 */ public function setDocType(DocType $docType, ServerRequestInterface $request): void { $this->docType = $docType; $this->xmlPrologAndDocType = $docType->getDoctypeDeclaration(); $this->setDefaultHtmlTag($request); } /** * Sets a given meta tag * * @param string $type The type of the meta tag. Allowed values are property, name or http-equiv * @param string $name The name of the property to add * @param string $content The content of the meta tag * @param array $subProperties Subproperties of the meta tag (like e.g. og:image:width) * @param bool $replace Replace earlier set meta tag */ public function setMetaTag(string $type, string $name, string $content, array $subProperties = [], bool $replace = true): void { // Lowercase all the things $type = strtolower($type); $name = strtolower($name); if (!in_array($type, ['property', 'name', 'http-equiv'], true)) { throw new \InvalidArgumentException( 'When setting a meta tag the only types allowed are property, name or http-equiv. "' . $type . '" given.', 1496402460 ); } $manager = $this->metaTagRegistry->getManagerForProperty($name); $manager->addProperty($name, $content, $subProperties, $replace, $type); } /** * Adds inline HTML comment. * FE only. */ public function addInlineComment(string $comment): void { if (!in_array($comment, $this->inlineComments)) { $this->inlineComments[] = $comment; } } /** * Adds header data * * @param string $data Free header data for HTML header */ public function addHeaderData(string $data): void { if (!in_array($data, $this->headerData)) { $this->headerData[] = $data; } } /** * Adds footer data * * @param string $data Free footer data for HTML footer before closing body tag */ public function addFooterData(string $data): void { if (!in_array($data, $this->footerData)) { $this->footerData[] = $data; } } /** * Adds JS Library. JS Library block is rendered on top of the JS files. * * @param string $name Arbitrary identifier * @param string|StaticResourceInterface $file File name * @param string|null $type Content Type * @param bool $forceOnTop Flag if added library should be inserted at begin of this block * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags * @param string $integrity Subresource Integrity (SRI) * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags * @param string $crossorigin CORS settings attribute * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags * @param array<string, string> $tagAttributes Key => value list of tag attributes */ public function addJsLibrary($name, $file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void { $resource = $this->handleAddedResource($file); $isUriResource = $resource instanceof UriResource; if ($type === null) { $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; } if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } if (!isset($this->jsLibs[strtolower($name)])) { $this->jsLibs[strtolower($name)] = [ 'file' => (string)$resource, 'type' => $type, 'section' => self::PART_HEADER, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'async' => $async, 'integrity' => $integrity, 'defer' => $defer, 'crossorigin' => $crossorigin, 'nomodule' => $nomodule, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds JS Library to Footer. JS Library block is rendered on top of the Footer JS files. * * @param string $name Arbitrary identifier * @param string|StaticResourceInterface $file File name * @param string|null $type Content Type * @param bool $forceOnTop Flag if added library should be inserted at begin of this block * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags * @param string $integrity Subresource Integrity (SRI) * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags * @param string $crossorigin CORS settings attribute * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags * @param array<string, string> $tagAttributes Key => value list of tag attributes */ public function addJsFooterLibrary($name, $file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void { $resource = $this->handleAddedResource($file); $isUriResource = $resource instanceof UriResource; if ($type === null) { $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; } if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } $name .= '_jsFooterLibrary'; if (!isset($this->jsLibs[strtolower($name)])) { $this->jsLibs[strtolower($name)] = [ 'file' => (string)$resource, 'type' => $type, 'section' => self::PART_FOOTER, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'async' => $async, 'integrity' => $integrity, 'defer' => $defer, 'crossorigin' => $crossorigin, 'nomodule' => $nomodule, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds JS file * * @param string|StaticResourceInterface $file File name * @param string|null $type Content Type * @param bool $forceOnTop * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags * @param string $integrity Subresource Integrity (SRI) * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags * @param string $crossorigin CORS settings attribute * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags * @param array<string, string> $tagAttributes Key => value list of tag attributes */ public function addJsFile($file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void { $resource = $this->handleAddedResource($file); $resourceIdentifier = (string)$resource; $isUriResource = $resource instanceof UriResource; if ($type === null) { $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; } if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } if (!isset($this->jsFiles[$resourceIdentifier])) { $this->jsFiles[$resourceIdentifier] = [ 'file' => $resourceIdentifier, 'type' => $type, 'section' => self::PART_HEADER, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'async' => $async, 'integrity' => $integrity, 'defer' => $defer, 'crossorigin' => $crossorigin, 'nomodule' => $nomodule, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds JS file to footer * * @param string|StaticResourceInterface $file File name * @param string|null $type Content Type * @param bool $forceOnTop * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags * @param string $integrity Subresource Integrity (SRI) * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags * @param string $crossorigin CORS settings attribute * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags * @param array<string, string> $tagAttributes Key => value list of tag attributes */ public function addJsFooterFile($file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void { $resource = $this->handleAddedResource($file); $resourceIdentifier = (string)$resource; $isUriResource = $resource instanceof UriResource; if ($type === null) { $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; } if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } if (!isset($this->jsFiles[$resourceIdentifier])) { $this->jsFiles[$resourceIdentifier] = [ 'file' => $resourceIdentifier, 'type' => $type, 'section' => self::PART_FOOTER, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'async' => $async, 'integrity' => $integrity, 'defer' => $defer, 'crossorigin' => $crossorigin, 'nomodule' => $nomodule, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds JS inline code * FE only. * * @param string $name * @param string $block * @param bool $forceOnTop */ public function addJsInlineCode($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void { if (!isset($this->jsInline[$name]) && !empty($block)) { $this->jsInline[$name] = [ 'code' => $block . LF, 'section' => self::PART_HEADER, 'forceOnTop' => $forceOnTop, 'csp' => $csp, ]; } } /** * Adds JS inline code to footer * FE only. * * @param string $name * @param string $block * @param bool $forceOnTop */ public function addJsFooterInlineCode($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void { if (!isset($this->jsInline[$name]) && !empty($block)) { $this->jsInline[$name] = [ 'code' => $block . LF, 'section' => self::PART_FOOTER, 'forceOnTop' => $forceOnTop, 'csp' => $csp, ]; } } /** * Adds CSS file * * @param string|StaticResourceInterface $file * @param string $rel * @param string $media * @param string $title * @param bool $forceOnTop * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $inline * @param array<string, string> $tagAttributes Key => value list of tag attributes * @param string $integrity Subresource Integrity (SRI) * @param string $crossorigin CORS settings attribute */ public function addCssFile($file, $rel = 'stylesheet', $media = 'all', $title = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $inline = false, array $tagAttributes = [], string $integrity = '', string $crossorigin = ''): void { $resource = $this->handleAddedResource($file); $isUriResource = $resource instanceof UriResource; if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } $resourceIdentifier = (string)$resource; if (!isset($this->cssFiles[$resourceIdentifier])) { $this->cssFiles[$resourceIdentifier] = [ 'file' => $resourceIdentifier, 'rel' => $rel, 'media' => $media, 'title' => $title, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'inline' => $inline, 'integrity' => $integrity, 'crossorigin' => $crossorigin, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds CSS library * * @param string|StaticResourceInterface $file * @param string $rel * @param string $media * @param string $title * @param bool $forceOnTop * @param string $allWrap * @param string $splitChar The char used to split the allWrap value, default is "|" * @param bool $inline * @param array<string, string> $tagAttributes Key => value list of tag attributes * @param string $integrity Subresource Integrity (SRI) * @param string $crossorigin CORS settings attribute */ public function addCssLibrary($file, $rel = 'stylesheet', $media = 'all', $title = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $inline = false, array $tagAttributes = [], string $integrity = '', string $crossorigin = ''): void { $resource = $this->handleAddedResource($file); $isUriResource = $resource instanceof UriResource; if ($integrity === ResourceHashCollection::AUTO) { $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; } if ($crossorigin === '' && $integrity !== '' && $isUriResource) { $crossorigin = 'anonymous'; } $resourceIdentifier = (string)$resource; if (!isset($this->cssLibs[$resourceIdentifier])) { $this->cssLibs[$resourceIdentifier] = [ 'file' => $resourceIdentifier, 'rel' => $rel, 'media' => $media, 'title' => $title, 'forceOnTop' => $forceOnTop, 'allWrap' => $allWrap, 'splitChar' => $splitChar, 'inline' => $inline, 'integrity' => $integrity, 'crossorigin' => $crossorigin, 'tagAttributes' => $tagAttributes, ]; } } /** * Adds CSS inline code * * @param string $name * @param string $block * @param bool $forceOnTop */ public function addCssInlineBlock($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void { if (!isset($this->cssInline[$name]) && !empty($block)) { $this->cssInline[$name] = [ 'code' => $block, 'forceOnTop' => $forceOnTop, 'csp' => $csp, ]; } } /** * Includes an ES6/ES11 compatible JavaScript module by * resolving the specifier to an import-mapped filename. * * @param string $specifier Bare module identifier like @my/package/filename.js */ public function loadJavaScriptModule(string $specifier): void { $this->javaScriptRenderer->addJavaScriptModuleInstruction( JavaScriptModuleInstruction::create($specifier) ); } /** * Adds Javascript Inline Label. This will occur in TYPO3.lang - object * The label can be used in scripts with TYPO3.lang.<key> * BE only. * * @param string $key * @param string $value */ public function addInlineLanguageLabel($key, $value): void { $this->inlineLanguageLabels[$key] = $value; } /** * Adds Javascript Inline Label Array. This will occur in TYPO3.lang - object * The label can be used in scripts with TYPO3.lang.<key> * Array will be merged with existing array. * BE only. */ public function addInlineLanguageLabelArray(array $array): void { $this->inlineLanguageLabels = array_merge($this->inlineLanguageLabels, $array); } /** * Gets labels to be used in JavaScript fetched from a locallang file. * * @param string $fileRef Input is a file-reference (see GeneralUtility::getFileAbsFileName). That file is expected to be a 'locallang.xlf' file containing a valid XML TYPO3 language structure. * @param string $selectionPrefix Prefix to select the correct labels (default: '') * @param string $stripFromSelectionName String to be removed from the label names in the output. (default: '') */ public function addInlineLanguageLabelFile($fileRef, $selectionPrefix = '', $stripFromSelectionName = ''): void { $index = md5($fileRef . $selectionPrefix . $stripFromSelectionName); if ($fileRef && !isset($this->inlineLanguageLabelFiles[$index])) { $this->inlineLanguageLabelFiles[$index] = [ 'fileRef' => $fileRef, 'selectionPrefix' => $selectionPrefix, 'stripFromSelectionName' => $stripFromSelectionName, ]; } } /** * Adds Javascript Inline Setting. This will occur in TYPO3.settings - object * The label can be used in scripts with TYPO3.setting.<key> * * @param string|null $namespace * @param string $key * @param mixed $value */ public function addInlineSetting($namespace, $key, $value): void { if ($namespace !== null && $namespace !== '') { if (strpos($namespace, '.')) { $parts = explode('.', $namespace); $a = &$this->inlineSettings; foreach ($parts as $part) { $a = &$a[$part]; } $a[$key] = $value; } else { $this->inlineSettings[$namespace][$key] = $value; } } else { $this->inlineSettings[$key] = $value; } } /** * Adds Javascript Inline Setting. This will occur in TYPO3.settings - object * The label can be used in scripts with TYPO3.setting.<key> * Array will be merged with existing array. * * @param string $namespace */ public function addInlineSettingArray($namespace, array $array): void { if ($namespace) { if (strpos($namespace, '.')) { $parts = explode('.', $namespace); $a = &$this->inlineSettings; foreach ($parts as $part) { $a = &$a[$part]; } $a = array_merge((array)$a, $array); } else { $this->inlineSettings[$namespace] = array_merge((array)($this->inlineSettings[$namespace] ?? []), $array); } } else { $this->inlineSettings = array_merge($this->inlineSettings, $array); } } /** * Adds content to body content */ public function addBodyContent(string $content): void { $this->bodyContent .= $content; } /** * Render the page. * BE only. * * @return string Content of rendered page */ public function render(ServerRequestInterface $request): string { $this->prepareRendering(); [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); $metaTags = implode(LF, $this->renderMetaTagsFromAPI($this->docType)); $markerArray = [ 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, 'HTMLTAG' => $this->htmlTag, 'HEADTAG' => $this->headTag, 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', 'CSS_LIBS' => $cssLibs, 'CSS_INCLUDE' => $cssFiles, 'CSS_INLINE' => $cssInline, 'JS_INLINE' => $jsInline, 'JS_INCLUDE' => $jsFiles, 'JS_LIBS' => $jsLibs, 'TITLE' => $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : '', 'META' => $metaTags, 'HEADERDATA' => $this->headerData ? implode(LF, $this->headerData) : '', 'FOOTERDATA' => $this->footerData ? implode(LF, $this->footerData) : '', 'JS_LIBS_FOOTER' => $jsFooterLibs, 'JS_INCLUDE_FOOTER' => $jsFooterFiles, 'JS_INLINE_FOOTER' => $jsFooterInline, 'BODY' => $this->bodyContent, // @internal 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', ]; $markerArray = array_map(trim(...), $markerArray); $template = $this->getTemplate(); // The page renderer needs a full reset when the page was rendered $this->reset($request); return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); } /** * Render the page for frontend output. * FE only. * * @internal Not part of the public API. Only for use in TYPO3 frontend rendering. */ public function renderFrontendPage(ServerRequestInterface $request): string { $this->prepareRendering(); [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); $metaTags = implode(LF, $this->renderMetaTagsFromAPI($this->docType)); $markerArray = [ 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, 'HTMLTAG' => $this->htmlTag, 'HEADTAG' => $this->headTag, 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', 'CSS_LIBS' => $cssLibs, 'CSS_INCLUDE' => $cssFiles, 'CSS_INLINE' => $cssInline, 'JS_INLINE' => $jsInline, 'JS_INCLUDE' => $jsFiles, 'JS_LIBS' => $jsLibs, 'TITLE' => $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : '', 'META' => $metaTags, 'HEADERDATA' => $this->headerData ? implode(LF, $this->headerData) : '', 'FOOTERDATA' => $this->footerData ? implode(LF, $this->footerData) : '', 'JS_LIBS_FOOTER' => $jsFooterLibs, 'JS_INCLUDE_FOOTER' => $jsFooterFiles, 'JS_INLINE_FOOTER' => $jsFooterInline, 'BODY' => $this->bodyContent, // @internal 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', ]; $markerArray = array_map(trim(...), $markerArray); $template = $this->getTemplate(); $this->reset($request); return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); } /** * BE only. */ public function renderResponse( ServerRequestInterface $request, int $code = 200, string $reasonPhrase = '', ): ResponseInterface { $stream = $this->streamFactory->createStream($this->render($request)); return $this->responseFactory->createResponse($code, $reasonPhrase) ->withHeader('Content-Type', 'text/html; charset=utf-8') ->withBody($stream); } /** * Frontend related rendering of the main page HTML scaffold with placeholders * for dynamic sections finished by uncached element ("INT") processing later. * The result of this method is cached as content in page cache. * FE only. * * @param string $substituteHash The hash that is used for the placeholder markers * @internal Never use in extensions. */ public function renderPageWithUncachedObjects(string $substituteHash): string { $this->prepareRendering(); $markerArray = [ 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, 'HTMLTAG' => $this->htmlTag, 'HEADTAG' => $this->headTag, 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', 'META' => '<!-- ###META' . $substituteHash . '### -->', 'BODY' => $this->bodyContent, 'TITLE' => '<!-- ###TITLE' . $substituteHash . '### -->', 'CSS_LIBS' => '<!-- ###CSS_LIBS' . $substituteHash . '### -->', 'CSS_INCLUDE' => '<!-- ###CSS_INCLUDE' . $substituteHash . '### -->', 'CSS_INLINE' => '<!-- ###CSS_INLINE' . $substituteHash . '### -->', 'JS_INLINE' => '<!-- ###JS_INLINE' . $substituteHash . '### -->', 'JS_INCLUDE' => '<!-- ###JS_INCLUDE' . $substituteHash . '### -->', 'JS_LIBS' => '<!-- ###JS_LIBS' . $substituteHash . '### -->', 'HEADERDATA' => '<!-- ###HEADERDATA' . $substituteHash . '### -->', 'FOOTERDATA' => '<!-- ###FOOTERDATA' . $substituteHash . '### -->', 'JS_LIBS_FOOTER' => '<!-- ###JS_LIBS_FOOTER' . $substituteHash . '### -->', 'JS_INCLUDE_FOOTER' => '<!-- ###JS_INCLUDE_FOOTER' . $substituteHash . '### -->', 'JS_INLINE_FOOTER' => '<!-- ###JS_INLINE_FOOTER' . $substituteHash . '### -->', // @internal 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', ]; // Reset body content to empty string so the content is not cached twice since it is // already cached as 'content' section next to the other PageRenderer state. $this->bodyContent = ''; $markerArray = array_map(trim(...), $markerArray); $template = $this->getTemplate(); // Note in contrast to render(), this method does *not* call $this->reset() since the PageRenderer state // is serialized and cached for uncached element processing. return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); } /** * Renders the JavaScript and CSS files that have been added during processing * of uncached content objects (USER_INT, COA_INT) * FE only. * * @param string $substituteHash The hash that is used for the variables * @internal Never use in extensions. */ public function renderJavaScriptAndCssForProcessingOfUncachedContentObjects(ServerRequestInterface $request, string $cachedPageContent, string $substituteHash): string { $this->prepareRendering(); // bodyContent is reset to empty string in FE both after render() and renderPageWithUncachedObjects(). // $this->bodyContent is set to the "cached with placeholder" string here for renderJavaScriptAndCss() // hook to consistently receive bodyContent, otherwise it wouldn't be needed to do this here. $this->bodyContent = $cachedPageContent; [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); $title = $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : ''; $markerArray = [ '<!-- ###TITLE' . $substituteHash . '### -->' => $title, '<!-- ###CSS_LIBS' . $substituteHash . '### -->' => $cssLibs, '<!-- ###CSS_INCLUDE' . $substituteHash . '### -->' => $cssFiles, '<!-- ###CSS_INLINE' . $substituteHash . '### -->' => $cssInline, '<!-- ###JS_INLINE' . $substituteHash . '### -->' => $jsInline, '<!-- ###JS_INCLUDE' . $substituteHash . '### -->' => $jsFiles, '<!-- ###JS_LIBS' . $substituteHash . '### -->' => $jsLibs, '<!-- ###META' . $substituteHash . '### -->' => implode(LF, $this->renderMetaTagsFromAPI($this->docType)), '<!-- ###HEADERDATA' . $substituteHash . '### -->' => implode(LF, $this->headerData), '<!-- ###FOOTERDATA' . $substituteHash . '### -->' => implode(LF, $this->footerData), '<!-- ###JS_LIBS_FOOTER' . $substituteHash . '### -->' => $jsFooterLibs, '<!-- ###JS_INCLUDE_FOOTER' . $substituteHash . '### -->' => $jsFooterFiles, '<!-- ###JS_INLINE_FOOTER' . $substituteHash . '### -->' => $jsFooterInline, ]; foreach ($markerArray as $placeHolder => $content) { $cachedPageContent = str_replace($placeHolder, $content, $cachedPageContent); } $this->reset($request); return $cachedPageContent; } /** * Reset all vars to initial values */ protected function reset(ServerRequestInterface $request): void { $this->locale = new Locale(); $this->setDocType(DocType::html5, $request); $this->templateFile = 'PKG:typo3/cms-core:Resources/Private/Templates/PageRenderer.html'; $this->bodyContent = ''; $this->jsFiles = []; $this->jsInline = []; $this->jsLibs = []; $this->cssFiles = []; $this->cssInline = []; $this->inlineComments = []; $this->headerData = []; $this->footerData = []; $this->javaScriptRenderer = JavaScriptRenderer::create('EXT:core/Resources/Public/JavaScript/java-script-item-handler.js'); } /** * Internal method to set a basic <html> tag when in HTML5 with the proper language/locale and "dir" attributes. */ protected function setDefaultHtmlTag(ServerRequestInterface $request): void { if ($this->docType === DocType::html5) { $attributes = [ 'lang' => $this->locale->getName(), ]; if ($this->locale->isRightToLeftLanguageDirection()) { $attributes['dir'] = 'rtl'; } // @todo: build an API to add HTML attributes cleanly if ($this->getApplicationType($request) === 'BE') { $backendUser = $this->context->getAspect('backend.user'); if ($backendUser->isLoggedIn()) { $attributes = array_merge($attributes, $this->getThemeAndColorSchemeHtmlTagAttributes($this->getBackendUser())); } } $this->setHtmlTag('<html ' . GeneralUtility::implodeAttributes($attributes, true) . '>'); } } private function getThemeAndColorSchemeHtmlTagAttributes(BackendUserAuthentication $backendUser): array { $attributes = []; $userTS = $backendUser->getTSConfig(); $themeDisabled = $userTS['setup.']['fields.']['theme.']['disabled'] ?? '0'; $theme = $backendUser->uc['theme'] ?? $userTS['setup.']['fields.']['theme'] ?? 'fresh'; if ($themeDisabled === '1') { $theme = $userTS['setup.']['fields.']['theme'] ?? 'fresh'; } if ($theme !== 'modern') { $attributes['data-theme'] = $theme; } $colorSchemeDisabled = $userTS['setup.']['fields.']['colorScheme.']['disabled'] ?? '0'; $colorScheme = $backendUser->uc['colorScheme'] ?? $userTS['setup.']['fields.']['colorScheme'] ?? 'auto'; if ($colorSchemeDisabled === '1') { $colorScheme = $userTS['setup.']['fields.']['colorScheme'] ?? 'light'; } if ($colorScheme !== 'auto') { $attributes['data-color-scheme'] = $colorScheme; } return $attributes; } /** * Renders metaTags based on tags added via the API */ protected function renderMetaTagsFromAPI(DocType $docType): array { $metaTags = []; $metaTagManagers = $this->metaTagRegistry->getAllManagers(); foreach ($metaTagManagers as $managerObject) { // @todo: Reflect $docType argument in MetaTagManagerInterface $properties = $managerObject->renderAllProperties($docType); // @phpstan-ignore arguments.count if (!empty($properties)) { $metaTags[] = $properties; } } return $metaTags; } /** * Remove ending slashes from static header block * if the page is being rendered as html (not xhtml) * and define property $this->endingSlash for further use */ protected function prepareRendering(): void { if ($this->docType->isXmlCompliant()) { $this->endingSlash = ' /'; } else { $this->shortcutTag = str_replace(' />', '>', $this->shortcutTag); $this->endingSlash = ''; } } /** * Renders all JavaScript and CSS * * @return string[] */ protected function renderJavaScriptAndCss(ServerRequestInterface $request): array { $this->executePreRenderHook(); $mainJsLibs = $this->renderMainJavaScriptLibraries($request); $this->executeRenderPostTransformHook(); $cssLibs = $this->renderCssLibraries($request); $cssFiles = $this->renderCssFiles($request); $cssInline = $this->renderCssInline(); [$jsLibs, $jsFooterLibs] = $this->renderAdditionalJavaScriptLibraries($request); [$jsFiles, $jsFooterFiles] = $this->renderJavaScriptFiles($request); [$jsInline, $jsFooterInline] = $this->renderInlineJavaScript(); $jsLibs = $mainJsLibs . $jsLibs; if ($this->moveJsFromHeaderToFooter) { $jsFooterLibs = $jsLibs . LF . $jsFooterLibs; $jsLibs = ''; $jsFooterFiles = $jsFiles . LF . $jsFooterFiles; $jsFiles = ''; $jsFooterInline = $jsInline . LF . $jsFooterInline; $jsInline = ''; } // Use AssetRenderer to inject all JavaScripts and CSS files $jsInline .= $this->assetRenderer->renderInlineJavaScript(true, $this->nonce); $jsFooterInline .= $this->assetRenderer->renderInlineJavaScript(false, $this->nonce); $jsFiles .= $this->assetRenderer->renderJavaScript(true, $this->nonce); $jsFooterFiles .= $this->assetRenderer->renderJavaScript(false, $this->nonce); $cssInline .= $this->assetRenderer->renderInlineStyleSheets(true, $this->nonce); // append inline CSS to footer (as there is no cssFooterInline) $jsFooterFiles .= $this->assetRenderer->renderInlineStyleSheets(false, $this->nonce); $cssLibs .= $this->assetRenderer->renderStyleSheets(true, $this->endingSlash, $this->nonce); $cssFiles .= $this->assetRenderer->renderStyleSheets(false, $this->endingSlash, $this->nonce); $this->executePostRenderHook($jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs); return [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs]; } /** * Reads the template file and returns the requested part as string */ protected function getTemplate(): string { $templateResource = $this->systemResourceFactory->createResource($this->templateFile); try { if ($templateResource instanceof SystemResourceInterface) { return $templateResource->getContents(); } } catch (SystemResourceDoesNotExistException) { } return ''; } /** * Helper function for render the main JavaScript libraries * * @return string Content with JavaScript libraries */ protected function renderMainJavaScriptLibraries(ServerRequestInterface $request): string { $out = ''; foreach ($this->assetCollector->getJavaScriptModules() as $module) { $this->loadJavaScriptModule($module); } // adds a nonce hint/work-around for lit-elements (which is only applied automatically in ShadowDOM) // see https://lit.dev/docs/api/ReactiveElement/#ReactiveElement.styles) if ($this->applyNonceHint && $this->nonce !== null) { $this->javaScriptRenderer->addGlobalAssignment(['litNonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)]); } $sitePath = $request->getAttribute('normalizedParams')->getSitePath(); $useNonce = $this->getApplicationType($request) === 'BE'; $out .= $this->javaScriptRenderer->renderImportMap( $sitePath, $useNonce ? $this->nonce : null, ); $this->loadJavaScriptLanguageStrings(); if ($this->getApplicationType($request) === 'BE') { $noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']); $this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn); $this->addGlobalCSSUrlsToInlineSettings($request); $this->inlineSettings['cache']['iconCacheIdentifier'] = sha1($this->iconRegistry->getBackendIconsCacheIdentifier()); } $assignments = array_filter([ 'settings' => $this->inlineSettings, 'lang' => $this->parseLanguageLabelsForJavaScript(), ]); if ($assignments !== []) { if ($this->getApplicationType($request) === 'BE') { $this->javaScriptRenderer->addGlobalAssignment(['TYPO3' => $assignments]); } else { $out .= $this->wrapInlineScript( sprintf( "var TYPO3 = Object.assign(TYPO3 || {}, %s);\r\n", // filter potential prototype pollution sprintf( 'Object.fromEntries(Object.entries(%s).filter((entry) => ' . "!['__proto__', 'prototype', 'constructor'].includes(entry[0])))", json_encode($assignments) ) ), $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : [] ); } } $out .= $this->javaScriptRenderer->render($this->nonce, $sitePath); return $out; } /** * Converts the language labels for usage in JavaScript */ protected function parseLanguageLabelsForJavaScript(): array { if (empty($this->inlineLanguageLabels)) { return []; } $labels = []; foreach ($this->inlineLanguageLabels as $key => $translationUnit) { if (is_array($translationUnit)) { $translationUnit = current($translationUnit); $labels[$key] = $translationUnit['target'] ?? $translationUnit['source']; } else { $labels[$key] = $translationUnit; } } return $labels; } /** * Load the language strings into JavaScript */ protected function loadJavaScriptLanguageStrings(): void { foreach ($this->inlineLanguageLabelFiles as $languageLabelFile) { $selectionPrefix = $languageLabelFile['selectionPrefix']; $stripFromSelectionName = $languageLabelFile['stripFromSelectionName']; $labelsFromFile = []; $allLabels = $this->readLLfile($languageLabelFile['fileRef']); // Iterate through all labels from the language file foreach ($allLabels as $label => $value) { // If $selectionPrefix is set, only respect labels that start with $selectionPrefix if ($selectionPrefix === '' || str_starts_with($label, $selectionPrefix)) { // Remove substring $stripFromSelectionName from label $label = str_replace($stripFromSelectionName, '', $label); $labelsFromFile[$label] = $value; } } $this->inlineLanguageLabels = array_merge($this->inlineLanguageLabels, $labelsFromFile); } $this->inlineLanguageLabelFiles = []; } /** * Make URLs to all backend ajax handlers available as inline setting. */ protected function addAjaxUrlsToInlineSettings(bool $publicRoutesOnly = false): void { $ajaxUrls = []; // Add the ajax-based routes $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); $router = GeneralUtility::makeInstance(Router::class); foreach ($router->getRoutes() as $routeIdentifier => $route) { if ($publicRoutesOnly && $route->getOption('access') !== 'public') { continue; } if ($route->getOption('ajax')) { $uri = (string)$uriBuilder->buildUriFromRoute($routeIdentifier); // use the shortened value in order to use this in JavaScript if (str_starts_with($routeIdentifier, 'ajax_')) { $routeIdentifier = substr($routeIdentifier, 5); } $ajaxUrls[$routeIdentifier] = $uri; } } $this->inlineSettings['ajaxUrls'] = $ajaxUrls; } protected function addGlobalCSSUrlsToInlineSettings(ServerRequestInterface $request): void { $this->inlineSettings['cssUrls'] = [ 'backend' => $this->getPublicUrlForFile('EXT:backend/Resources/Public/Css/backend.css', $request), ]; } /** * Render CSS library files */ protected function renderCssLibraries(ServerRequestInterface $request): string { $cssFiles = ''; if (!empty($this->cssLibs)) { foreach ($this->cssLibs as $properties) { $tag = $this->createCssTag($properties, $properties['file'], $request); if ($properties['forceOnTop'] ?? false) { $cssFiles = $tag . $cssFiles; } else { $cssFiles .= $tag; } } } return $cssFiles; } /** * Render CSS files */ protected function renderCssFiles(ServerRequestInterface $request): string { $cssFiles = ''; if (!empty($this->cssFiles)) { foreach ($this->cssFiles as $properties) { $tag = $this->createCssTag($properties, $properties['file'], $request); if ($properties['forceOnTop'] ?? false) { $cssFiles = $tag . $cssFiles; } else { $cssFiles .= $tag; } } } return $cssFiles; } /** * Adds a CSP hash for a static file to the hash collection. * Resolves PKG:, EXT: and relative public paths via SystemResourceFactory. * Silently skips URI resources (http/https) and unresolvable paths. */ private function addFileHashToCollection(Directive $directive, string $file): void { $resource = $this->systemResourceFactory->createResource($file); if ($resource instanceof SystemResourceInterface) { $this->directiveHashCollection->addResourceHash($directive, $resource); } } /** * Create link (inline=0) or style (inline=1) tag */ private function createCssTag(array $properties, string $file, ServerRequestInterface $request): string { $includeInline = $properties['inline'] ?? false; $resource = $includeInline ? $this->systemResourceFactory->createResource($file) : null; if ($resource instanceof SystemResourceInterface) { $tag = $this->createInlineCssTagFromFile($resource, $properties, $request); } else { // collect CSP hash - use integrity attribute if given, else hash file content $integrity = $properties['integrity'] ?? ''; if ($integrity !== '') { try { $this->directiveHashCollection->addGenericHashValue(Directive::StyleSrcElem, $integrity); } catch (\LogicException) { // integrity format not recognized, skip } } else { $this->addFileHashToCollection(Directive::StyleSrcElem, $file); } $tagAttributes = []; if ($properties['rel'] ?? false) { $tagAttributes['rel'] = $properties['rel']; } $tagAttributes['href'] = $this->getPublicUrlForFile($file, $request); if ($properties['media'] ?? false) { $tagAttributes['media'] = $properties['media']; } if ($properties['title'] ?? false) { $tagAttributes['title'] = $properties['title']; } if ($properties['integrity'] ?? false) { $tagAttributes['integrity'] = $properties['integrity']; } if ($properties['crossorigin'] ?? false) { $tagAttributes['crossorigin'] = $properties['crossorigin']; } // use nonce if given if ($this->nonce !== null) { $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::StyleSrcElem); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<link ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . $this->endingSlash . '>'; } if ($properties['allWrap'] ?? false) { $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); $tag = $wrapArr[0] . $tag . $wrapArr[1]; } $tag .= LF; return $tag; } /** * Render inline CSS */ protected function renderCssInline(): string { if (empty($this->cssInline)) { return ''; } $cssItems = [0 => [], 1 => []]; foreach ($this->cssInline as $name => $properties) { $useCsp = !empty($properties['csp']); $nonceKey = (int)$useCsp; $cssCode = '/*' . htmlspecialchars($name) . '*/' . LF . ($properties['code'] ?? '') . LF; if ($properties['forceOnTop'] ?? false) { array_unshift($cssItems[$nonceKey], $cssCode); } else { $cssItems[$nonceKey][] = $cssCode; } } $cssItems = array_filter($cssItems); foreach ($cssItems as $useCsp => $items) { $assembledContent = implode('', $items); if ($useCsp) { // Hash the full assembled content as it appears inside the <style> tag $this->directiveHashCollection->addInlineHash(Directive::StyleSrcElem, LF . $assembledContent . LF); } $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::StyleSrcElem)] : []; $cssItems[$useCsp] = $this->wrapInlineStyle($assembledContent, $attributes); } return implode(LF, $cssItems); } /** * Render JavaScript libraries * * @return string[] jsLibs and jsFooterLibs strings */ protected function renderAdditionalJavaScriptLibraries(ServerRequestInterface $request): array { $jsLibs = ''; $jsFooterLibs = ''; if (!empty($this->jsLibs)) { foreach ($this->jsLibs as $properties) { // collect CSP hash - use integrity attribute if given, else hash file content $integrity = $properties['integrity'] ?? ''; if ($integrity !== '') { try { $this->directiveHashCollection->addGenericHashValue(Directive::ScriptSrcElem, $integrity); } catch (\LogicException) { // integrity format not recognized, skip } } else { $this->addFileHashToCollection(Directive::ScriptSrcElem, $properties['file']); } $tagAttributes = []; $tagAttributes['src'] = $this->getPublicUrlForFile($properties['file'], $request); if ($properties['type'] ?? false) { $tagAttributes['type'] = $properties['type']; } if ($properties['async'] ?? false) { $tagAttributes['async'] = 'async'; } if ($properties['defer'] ?? false) { $tagAttributes['defer'] = 'defer'; } if ($properties['nomodule'] ?? false) { $tagAttributes['nomodule'] = 'nomodule'; } if ($properties['integrity'] ?? false) { $tagAttributes['integrity'] = $properties['integrity']; } if ($properties['crossorigin'] ?? false) { $tagAttributes['crossorigin'] = $properties['crossorigin']; } // use nonce if given if ($this->nonce !== null) { $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::ScriptSrcElem); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; if ($properties['allWrap'] ?? false) { $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); $tag = $wrapArr[0] . $tag . $wrapArr[1]; } $tag .= LF; if ($properties['forceOnTop'] ?? false) { if (($properties['section'] ?? 0) === self::PART_HEADER) { $jsLibs = $tag . $jsLibs; } else { $jsFooterLibs = $tag . $jsFooterLibs; } } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { $jsLibs .= $tag; } else { $jsFooterLibs .= $tag; } } } if ($this->moveJsFromHeaderToFooter) { $jsFooterLibs = $jsLibs . LF . $jsFooterLibs; $jsLibs = ''; } return [$jsLibs, $jsFooterLibs]; } /** * Render JavaScript files * * @return string[] jsFiles and jsFooterFiles strings */ protected function renderJavaScriptFiles(ServerRequestInterface $request): array { $jsFiles = ''; $jsFooterFiles = ''; if (!empty($this->jsFiles)) { foreach ($this->jsFiles as $properties) { // collect CSP hash - use integrity attribute if given, else hash file content $integrity = $properties['integrity'] ?? ''; if ($integrity !== '') { try { $this->directiveHashCollection->addGenericHashValue(Directive::ScriptSrcElem, $integrity); } catch (\LogicException) { // integrity format not recognized, skip } } else { $this->addFileHashToCollection(Directive::ScriptSrcElem, $properties['file']); } $tagAttributes = []; $tagAttributes['src'] = $this->getPublicUrlForFile($properties['file'], $request); if ($properties['type'] ?? false) { $tagAttributes['type'] = $properties['type']; } if ($properties['async'] ?? false) { $tagAttributes['async'] = 'async'; } if ($properties['defer'] ?? false) { $tagAttributes['defer'] = 'defer'; } if ($properties['nomodule'] ?? false) { $tagAttributes['nomodule'] = 'nomodule'; } if ($properties['integrity'] ?? false) { $tagAttributes['integrity'] = $properties['integrity']; } if ($properties['crossorigin'] ?? false) { $tagAttributes['crossorigin'] = $properties['crossorigin']; } // use nonce if given if ($this->nonce !== null) { $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::ScriptSrcElem); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; if ($properties['allWrap'] ?? false) { $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); $tag = $wrapArr[0] . $tag . $wrapArr[1]; } $tag .= LF; if ($properties['forceOnTop'] ?? false) { if (($properties['section'] ?? 0) === self::PART_HEADER) { $jsFiles = $tag . $jsFiles; } else { $jsFooterFiles = $tag . $jsFooterFiles; } } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { $jsFiles .= $tag; } else { $jsFooterFiles .= $tag; } } } if ($this->moveJsFromHeaderToFooter) { $jsFooterFiles = $jsFiles . $jsFooterFiles; $jsFiles = ''; } return [$jsFiles, $jsFooterFiles]; } /** * Render inline JavaScript (must not apply `nonce="..."` if defined). * * @return string[] jsInline and jsFooterInline string */ protected function renderInlineJavaScript(): array { if (empty($this->jsInline)) { return ['', '']; } $regularItems = [0 => [], 1 => []]; $footerItems = [0 => [], 1 => []]; foreach ($this->jsInline as $name => $properties) { $useCsp = !empty($properties['csp']); $nonceKey = (int)$useCsp; $jsCode = '/*' . htmlspecialchars($name) . '*/' . LF . ($properties['code'] ?? '') . LF; if ($properties['forceOnTop'] ?? false) { if (($properties['section'] ?? 0) === self::PART_HEADER) { array_unshift($regularItems[$nonceKey], $jsCode); } else { array_unshift($footerItems[$nonceKey], $jsCode); } } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { $regularItems[$nonceKey][] = $jsCode; } else { $footerItems[$nonceKey][] = $jsCode; } } $regularItems = array_filter($regularItems); $footerItems = array_filter($footerItems); foreach ($regularItems as $useCsp => $items) { $assembledContent = implode('', $items); if ($useCsp) { // Hash the full assembled content as it appears inside the <script> tag $this->directiveHashCollection->addInlineHash(Directive::ScriptSrcElem, LF . $assembledContent . LF); } $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : []; $regularItems[$useCsp] = $this->wrapInlineScript($assembledContent, $attributes); } foreach ($footerItems as $useCsp => $items) { $assembledContent = implode('', $items); if ($useCsp) { // Hash the full assembled content as it appears inside the <script> tag $this->directiveHashCollection->addInlineHash(Directive::ScriptSrcElem, LF . $assembledContent . LF); } $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : []; $footerItems[$useCsp] = $this->wrapInlineScript($assembledContent, $attributes); } $regularCode = implode(LF, $regularItems); $footerCode = implode(LF, $footerItems); if ($this->moveJsFromHeaderToFooter) { $footerCode = $regularCode . $footerCode; $regularCode = ''; } return [$regularCode, $footerCode]; } /** * Reads a locallang file. * * @param string $fileRef Reference to a relative filename to include. * @return array Returns the $LOCAL_LANG array found in the file. If no array found, returns empty array. */ protected function readLLfile(string $fileRef): array { $languageService = $this->languageServiceFactory->create($this->locale); return $languageService->getLabelsFromResource($fileRef); } private function handleAddedResource(string|StaticResourceInterface $potentialResource): StaticResourceInterface { if ($potentialResource instanceof StaticResourceInterface) { return $potentialResource; } return $this->systemResourceFactory->createResource($potentialResource); } /** * This function acts as a wrapper to allow relative and paths starting with EXT: to be dealt with * in this very case to always return the "absolute web path" to be included directly before output. * * This is mainly added so the EXT: syntax can be resolved for PageRenderer in one central place, * and hopefully removed in the future by one standard API call. * * The file is also prepared as version numbered file and prefixed as absolute webpath * * @param string $file the filename to process */ protected function getPublicUrlForFile(string $file, ServerRequestInterface $request): string { $resource = $this->systemResourceFactory->createPublicResource($file); return (string)$this->resourcePublisher->generateUri($resource, $request); } /** * Execute PreRenderHook for possible manipulation */ protected function executePreRenderHook(): void { $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-preProcess'] ?? false; if (!$hooks) { return; } // @todo: $jsFooterFiles and $jsFooterInline and $jsFooterLibs can be removed once the hook is adapted / replaced $jsFooterFiles = $jsFooterLibs = $jsFooterInline = []; $params = [ 'jsLibs' => &$this->jsLibs, 'jsFooterLibs' => &$jsFooterLibs, 'jsFiles' => &$this->jsFiles, 'jsFooterFiles' => &$jsFooterFiles, 'cssLibs' => &$this->cssLibs, 'cssFiles' => &$this->cssFiles, 'headerData' => &$this->headerData, 'footerData' => &$this->footerData, 'jsInline' => &$this->jsInline, 'jsFooterInline' => &$jsFooterInline, 'cssInline' => &$this->cssInline, ]; foreach ($hooks as $hook) { GeneralUtility::callUserFunction($hook, $params, $this); } } /** * PostTransform for possible manipulation of concatenated and compressed files */ protected function executeRenderPostTransformHook(): void { $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-postTransform'] ?? false; if (!$hooks) { return; } // @todo: $jsFooterFiles and $jsFooterInline and $jsFooterLibs can be removed once the hook is adapted / replaced $jsFooterFiles = $jsFooterLibs = $jsFooterInline = []; $params = [ 'jsLibs' => &$this->jsLibs, 'jsFooterLibs' => &$jsFooterLibs, 'jsFiles' => &$this->jsFiles, 'jsFooterFiles' => &$jsFooterFiles, 'cssLibs' => &$this->cssLibs, 'cssFiles' => &$this->cssFiles, 'headerData' => &$this->headerData, 'footerData' => &$this->footerData, 'jsInline' => &$this->jsInline, 'jsFooterInline' => &$jsFooterInline, 'cssInline' => &$this->cssInline, ]; foreach ($hooks as $hook) { GeneralUtility::callUserFunction($hook, $params, $this); } } /** * Execute postRenderHook for possible manipulation * * @param string $jsLibs * @param string $jsFiles * @param string $jsFooterFiles * @param string $cssLibs * @param string $cssFiles * @param string $jsInline * @param string $cssInline * @param string $jsFooterInline * @param string $jsFooterLibs */ protected function executePostRenderHook(&$jsLibs, &$jsFiles, &$jsFooterFiles, &$cssLibs, &$cssFiles, &$jsInline, &$cssInline, &$jsFooterInline, &$jsFooterLibs): void { $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-postProcess'] ?? false; if (!$hooks) { return; } $params = [ 'jsLibs' => &$jsLibs, 'jsFiles' => &$jsFiles, 'jsFooterFiles' => &$jsFooterFiles, 'cssLibs' => &$cssLibs, 'cssFiles' => &$cssFiles, 'headerData' => &$this->headerData, 'footerData' => &$this->footerData, 'jsInline' => &$jsInline, 'cssInline' => &$cssInline, 'xmlPrologAndDocType' => &$this->xmlPrologAndDocType, 'htmlTag' => &$this->htmlTag, 'headTag' => &$this->headTag, 'shortcutTag' => &$this->shortcutTag, 'inlineComments' => &$this->inlineComments, 'favIcon' => &$this->favIcon, 'iconMimeType' => &$this->iconMimeType, 'titleTag' => &$this->titleTag, 'title' => &$this->title, 'jsFooterInline' => &$jsFooterInline, 'jsFooterLibs' => &$jsFooterLibs, 'bodyContent' => &$this->bodyContent, ]; foreach ($hooks as $hook) { GeneralUtility::callUserFunction($hook, $params, $this); } } /** * Creates a CSS inline tag * * @param SystemResourceInterface $resource the resource to process */ protected function createInlineCssTagFromFile(SystemResourceInterface $resource, array $properties, ServerRequestInterface $request): string { try { $cssInline = $resource->getContents(); } catch (SystemResourceDoesNotExistException) { return ''; } $cssInlineFix = $this->relativeCssPathFixer->fixRelativeUrlPaths($cssInline, PathUtility::dirname($resource->getResourceIdentifier()) . '/', $request); // collect CSP hash - covers the content as it appears inside the <style> tag $this->directiveHashCollection->addInlineHash(Directive::StyleSrcElem, LF . $cssInlineFix . LF); $tagAttributes = []; if ($properties['media'] ?? false) { $tagAttributes['media'] = $properties['media']; } if ($properties['title'] ?? false) { $tagAttributes['title'] = $properties['title']; } // use nonce if given - special case, since content is created from a static file if ($this->nonce !== null) { $tagAttributes['nonce'] = $this->nonce->consumeInline(Directive::StyleSrcElem); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); return $this->wrapInlineStyle($cssInlineFix, $tagAttributes); } protected function wrapInlineStyle(string $content, array $attributes = []): string { $styleTag = "<style%s>\n%s\n</style>\n"; if ($this->docType !== DocType::html5 || $this->docType->isXmlCompliant()) { $styleTag = "<style%s>\n/*<![CDATA[*/\n<!-- \n%s-->\n/*]]>*/\n</style>\n"; } $attributesList = GeneralUtility::implodeAttributes($attributes, true); return sprintf( $styleTag, $attributesList !== '' ? ' ' . $attributesList : '', $content ); } protected function wrapInlineScript(string $content, array $attributes = []): string { $scriptTag = "<script%s>\n%s\n</script>\n"; // * Whenever HTML5 is used, remove the "text/javascript" type from the wrap // since this is not needed and may lead to validation errors in the future. // * Whenever XHTML gets disabled, remove the "text/javascript" type from the wrap // since this is not needed and may lead to validation errors in the future. if ($this->docType !== DocType::html5 || $this->docType->isXmlCompliant()) { $attributes['type'] = 'text/javascript'; $scriptTag = "<script%s>\n/*<![CDATA[*/\n%s/*]]>*/\n</script>\n"; } $attributesList = GeneralUtility::implodeAttributes($attributes, true); return sprintf( $scriptTag, $attributesList !== '' ? ' ' . $attributesList : '', $content ); } /** * String 'FE' if in FrontendApplication, 'BE' otherwise (also in CLI without request object) */ protected function getApplicationType(ServerRequestInterface $request): string { if (ApplicationType::fromRequest($request)->isFrontend()) { return 'FE'; } return 'BE'; } private function getBackendUser(): BackendUserAuthentication { if (!$GLOBALS['BE_USER'] instanceof BackendUserAuthentication) { throw new \RuntimeException('No backend user found.', 1765402790); } return $GLOBALS['BE_USER']; } }