createSettings( $site, $sysTemplateRows, $expressionMatcherVariables, $typoScriptCache ); $setupDetails = $this->createSetupConditionList( $site, $sysTemplateRows, $expressionMatcherVariables, $typoScriptCache, $settingsDetails['flatSettings'], $settingsDetails['settingsConditionList'], ); $frontendTypoScript = new FrontendTypoScript( $settingsDetails['settingsTree'], $settingsDetails['settingsConditionList'], $settingsDetails['flatSettings'], $setupDetails['setupConditionList'], ); if ($setupDetails['setupIncludeTree']) { $frontendTypoScript->setSetupIncludeTree($setupDetails['setupIncludeTree']); } return $frontendTypoScript; } /** * Calculate settings (formerly "constants"). * * The page cache entry identifier depends on setup TypoScript: A single page with two different * setup TypoScript AST will probably render different results, thus two page-cache entries. * Setup TypoScript can be different when setup conditions match differently. * Setup conditions can use settings "[{$foo} = 42]". * * All FE requests thus need the current list of settings, and settings can have conditions, too. * We thus *always* need the current list of settings, even in fully cached pages context. * * The method calculates settings and uses caches as much as possible: * * settingsTree: The full settings AST * * flatSettings: Flattened list of settings, derived from settings AST * * settingsConditionList: Settings conditions with verdicts of this Request * * @return array{settingsTree: RootNode, flatSettings: array, settingsConditionList: array} */ private function createSettings( SiteInterface $site, array $sysTemplateRows, array $expressionMatcherVariables, ?PhpFrontend $typoScriptCache, ): array { $cacheCriteria = [ 'sysTemplateRows' => $sysTemplateRows, ]; if ($site instanceof Site && $site->isTypoScriptRoot()) { $cacheCriteria['siteIdentifier'] = $site->getIdentifier(); } $conditionTreeCacheIdentifier = 'settings-condition-tree-' . hash('xxh3', json_encode($cacheCriteria, JSON_THROW_ON_ERROR)); if ($conditionTree = $typoScriptCache?->require($conditionTreeCacheIdentifier)) { // Got the (flat) include tree of all settings conditions for this TypoScript combination from cache. // Good. Traverse this list to calculate "current" condition verdicts. Hash this list together with a // hash of the TypoScript sys_templates, and try to retrieve the full settings TypoScript AST from cache. // Note: Working with the derived condition tree that *only* contains conditions, but not the full // include tree is a trick: We only need the condition verdicts to know the AST cache identifier, // and traversing the flat condition tree is quicker than traversing the entire settings include tree, // since it only scales with the number of settings conditions and not with the full amount of TypoScript // settings. The same trick is used for the setup AST cache later. $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); // It does not matter if we use IncludeTreeTraverser or ConditionVerdictAwareIncludeTreeTraverser here: // Conditions list is flat, not nested. IncludeTreeTraverser has an if() less, so we use that one. $this->includeTreeTraverser->traverse($conditionTree, [$conditionMatcherVisitor]); $conditionList = $conditionMatcherVisitor->getConditionListWithVerdicts(); $settings = $typoScriptCache->require( 'settings-' . hash('xxh3', $conditionTreeCacheIdentifier . json_encode($conditionList, JSON_THROW_ON_ERROR)) ); if (is_array($settings)) { return [ 'settingsTree' => $settings['ast'], 'flatSettings' => $settings['flatSettings'], 'settingsConditionList' => $conditionList, ]; } } // We did not get settings from cache, or are not allowed to use cache. Build settings from scratch. // We fetch the full settings include tree (from cache if possible), register the condition // matcher and register the AST builder and traverse include tree to retrieve settings AST and derive // 'flat settings' from it. Both are cached if allowed afterward for the above 'if' to kick in next time. $includeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('constants', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); $visitors = []; $visitors[] = $conditionMatcherVisitor; $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); $visitors[] = $astBuilderVisitor; // We must use ConditionVerdictAwareIncludeTreeTraverser here: This one does not walk into // children for not matching conditions, which is important to create the correct AST. $this->includeTreeTraverserConditionVerdictAware->traverse($includeTree, $visitors); $tree = $astBuilderVisitor->getAst(); // @internal Dispatch an experimental event allowing listeners to still change the settings AST, // to for instance implement nested constants if really needed. Note this event may change // or vanish later without further notice. $tree = $this->eventDispatcher->dispatch(new ModifyTypoScriptConstantsEvent($tree))->getConstantsAst(); $flatSettings = $tree->flatten(); // Prepare the full list of settings conditions in order to cache this list, avoiding the // settings AST building next time. We need all conditions of the entire include tree, but the // above ConditionVerdictAwareIncludeTreeTraverser did not find nested conditions if an upper // condition did not match. We thus have to traverse include tree a second time with the // IncludeTreeTraverser. This one does traverse into not matching conditions. $visitors = []; $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); $visitors[] = $conditionMatcherVisitor; $conditionTreeAccumulatorVisitor = null; if (!$conditionTree && $typoScriptCache) { // If the settingsConditionTree did not come from cache above and if we are allowed to cache, // register the visitor that creates the settings condition include tree, to cache it. $conditionTreeAccumulatorVisitor = $this->container->get(IncludeTreeConditionIncludeListAccumulatorVisitor::class); $visitors[] = $conditionTreeAccumulatorVisitor; } $this->includeTreeTraverser->traverse($includeTree, $visitors); $conditionList = $conditionMatcherVisitor->getConditionListWithVerdicts(); if ($conditionTreeAccumulatorVisitor) { // Cache the flat condition include tree for next run. $conditionTree = $conditionTreeAccumulatorVisitor->getConditionIncludes(); $typoScriptCache?->set( $conditionTreeCacheIdentifier, 'return unserialize(\'' . addcslashes(serialize($conditionTree), '\'\\') . '\');' ); } $typoScriptCache?->set( // Cache full AST and the derived 'flattened' variant for next run, which will kick in if // the sys_templates and condition verdicts are identical with another Request. 'settings-' . hash('xxh3', $conditionTreeCacheIdentifier . json_encode($conditionList, JSON_THROW_ON_ERROR)), 'return unserialize(\'' . addcslashes(serialize(['ast' => $tree, 'flatSettings' => $flatSettings]), '\'\\') . '\');' ); return [ 'settingsTree' => $tree, 'flatSettings' => $flatSettings, 'settingsConditionList' => $conditionList, ]; } /** * Calculate setup condition verdicts. * * With settings being done, the list of matching setup condition verdicts is calculated, * which depend on settings. Setup conditions with their verdicts are part of the page * cache identifier, they are *always* needed in the FE rendering chain. * * The cached variant uses a similar trick as with the settings calculation above: We * calculate a flat tree of all conditions and cache this, so the traverser only needs * to iterate the conditions to calculate their verdicts, but not the entire include * tree next time. * * The method returns: * * 'setupConditionList': Setup conditions with verdicts of this Request * * (sometimes) setupIncludeTree: The setup include tree *if* it had to be calculated. Used internally * to suppress a second calculation in createSetupConfigOrFullSetup(). * * @return array{setupConditionList: array, setupIncludeTree: RootInclude|null} */ private function createSetupConditionList( SiteInterface $site, array $sysTemplateRows, array $expressionMatcherVariables, ?PhpFrontend $typoScriptCache, array $flatSettings, array $settingsConditionList, ): array { $conditionTreeCacheIdentifier = 'setup-condition-tree-' . hash( 'xxh3', json_encode($sysTemplateRows, JSON_THROW_ON_ERROR) . json_encode($site instanceof Site && $site->isTypoScriptRoot() ? $site->getSets() : '', JSON_THROW_ON_ERROR) . json_encode($settingsConditionList, JSON_THROW_ON_ERROR) ); if ($conditionTree = $typoScriptCache?->require($conditionTreeCacheIdentifier)) { // We got the flat list of all setup conditions for this TypoScript combination from cache. Good. We traverse // this list to calculate "current" condition verdicts, which we need as hash to be part of page cache identifier. // We're done and return. Note 'setupIncludeTree' is *not* returned in this case since it is not needed and // may or may not be needed later, depending on if we can get a page cache entry later and if it has _INT objects. $visitors = []; $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); $conditionConstantSubstitutionVisitor->setFlattenedConstants($flatSettings); $visitors[] = $conditionConstantSubstitutionVisitor; $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); $visitors[] = $conditionMatcherVisitor; // It does not matter if we use IncludeTreeTraverser or ConditionVerdictAwareIncludeTreeTraverser here: // Condition list is flat, not nested. IncludeTreeTraverser has an if() less, so we use that one. $this->includeTreeTraverser->traverse($conditionTree, $visitors); return [ 'setupConditionList' => $conditionMatcherVisitor->getConditionListWithVerdicts(), 'setupIncludeTree' => null, ]; } // We did not get setup condition list from cache, or are not allowed to use cache. We have to build setup // condition list from scratch. This means we'll fetch the full setup include tree (from cache if possible), // register the constant substitution visitor, the condition matcher and the condition accumulator visitor. $includeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); $visitors = []; $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); $conditionConstantSubstitutionVisitor->setFlattenedConstants($flatSettings); $visitors[] = $conditionConstantSubstitutionVisitor; $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); $visitors[] = $conditionMatcherVisitor; $conditionTreeAccumulatorVisitor = $this->container->get(IncludeTreeConditionIncludeListAccumulatorVisitor::class); $visitors[] = $conditionTreeAccumulatorVisitor; // It is important to use IncludeTreeTraverser here: We need the condition verdicts of *all* conditions, and // we want to accumulate all of them. The ConditionVerdictAwareIncludeTreeTraverser wouldn't walk into nested // conditions if an upper one does not match, which defeats cache identifier calculations. $this->includeTreeTraverser->traverse($includeTree, $visitors); $typoScriptCache?->set( $conditionTreeCacheIdentifier, 'return unserialize(\'' . addcslashes(serialize($conditionTreeAccumulatorVisitor->getConditionIncludes()), '\'\\') . '\');' ); return [ 'setupConditionList' => $conditionMatcherVisitor->getConditionListWithVerdicts(), 'setupIncludeTree' => $includeTree, ]; } /** * Enrich the given FrontendTypoScript object with TypoScript 'setup' relevant data. * * The method is called in FE after an attempt to retrieve page content from cache has * been done. There are three possible outcomes: * * The page has been retrieved from cache and the content *does not* contain uncached "_INT" objects * * The page has been retrieved from cache and the content *does* contain uncached "_INT" objects * * The page could not be retrieved from cache * * If the page could not be retrieved from cache, or if the cached page content contains "_INT" objects, * flag $needsFullSetup is given true, and the full TypoScript is calculated since at least parts of * the page content has to be rendered, which then needs full TypoScript. * If the page could be retrieved from cache, and contains no "_INT" objects, $needsFullSetup in false, the * rendering chain only needs the "config." part of TypoScript to satisfy the remaining middlewares. * * The method implements these variants and tries to add as little overhead as possible. * * Returns the FrontendTypoScript object: * * configTree: Always set. Global TypoScript 'config.' merged with overrides from given type/typeNum "page.config.". * * configArray: Always set. Array representation of configTree. * * setupTree: Not set if $needsFullSetup=false and configTree could be retrieved from cache. Full TypoScript setup. * * setupArray: Not set if $needsFullSetup=false and configTree could be retrieved from cache. * Array representation of setupTree. * * pageTree: Not set if $needsFullSetup=false and configTree could be retrieved from cache, or if no PAGE object * could be determined. The 'PAGE' object tree for given type/typeNum. * * pageArray: Not set if $needsFullSetup=false and configTree could be retrieved from cache, or if no PAGE object * could be determined. Array representation of PageTree. */ public function createSetupConfigOrFullSetup( bool $needsFullSetup, FrontendTypoScript $frontendTypoScript, SiteInterface $site, array $sysTemplateRows, array $expressionMatcherVariables, string $type, ?PhpFrontend $typoScriptCache, ?ServerRequestInterface $request, ): FrontendTypoScript { $setupTypoScriptCacheIdentifier = 'setup-' . hash( 'xxh3', json_encode($sysTemplateRows, JSON_THROW_ON_ERROR) . ($site instanceof Site && $site->isTypoScriptRoot() ? $site->getIdentifier() : '') . json_encode($frontendTypoScript->getSettingsConditionList(), JSON_THROW_ON_ERROR) . json_encode($frontendTypoScript->getSetupConditionList(), JSON_THROW_ON_ERROR) ); $setupConfigTypoScriptCacheIdentifier = 'setup-config-' . hash('xxh3', $setupTypoScriptCacheIdentifier . $type); $gotSetupConfigFromCache = false; if ($setupConfigTypoScriptCache = $typoScriptCache?->require($setupConfigTypoScriptCacheIdentifier)) { $frontendTypoScript->setConfigTree($setupConfigTypoScriptCache['ast']); $frontendTypoScript->setConfigArray($setupConfigTypoScriptCache['array']); if (!$needsFullSetup) { // Fully cached page context without _INT - only 'config' is needed. Return early. return $frontendTypoScript; } $gotSetupConfigFromCache = true; } $setupRawConfigAst = null; if (!$typoScriptCache || $needsFullSetup || !$gotSetupConfigFromCache) { // If caching is not allowed, if no page cache entry could be loaded or if the page cache entry has _INT // object, we need the full setup AST. Try to use a cache entry for setup AST, which especially up _INT // parsing. In unavailable, calculate full setup AST and cache it if allowed. $gotSetupFromCache = false; if ($setupTypoScriptCache = $typoScriptCache?->require($setupTypoScriptCacheIdentifier)) { // We need AST, and we got it from cache. $frontendTypoScript->setSetupTree($setupTypoScriptCache['ast']); $frontendTypoScript->setSetupArray($setupTypoScriptCache['array']); $setupRawConfigAst = $setupTypoScriptCache['ast']->getChildByName('config'); $gotSetupFromCache = true; } if (!$typoScriptCache || !$gotSetupFromCache) { // We need AST and couldn't get it from cache or are now allowed to. We thus need the full setup // IncludeTree, which we can get from cache again if allowed, or is calculated a-new if not. $setupIncludeTree = $frontendTypoScript->getSetupIncludeTree(); if (!$typoScriptCache || $setupIncludeTree === null) { // A previous method *may* have calculated setup include tree already. Calculate now if not. $setupIncludeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); } $visitors = []; $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); $conditionConstantSubstitutionVisitor->setFlattenedConstants($frontendTypoScript->getFlatSettings()); $visitors[] = $conditionConstantSubstitutionVisitor; $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); $visitors[] = $conditionMatcherVisitor; $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); $astBuilderVisitor->setFlatConstants($frontendTypoScript->getFlatSettings()); $visitors[] = $astBuilderVisitor; $this->includeTreeTraverserConditionVerdictAware->traverse($setupIncludeTree, $visitors); $setupAst = $astBuilderVisitor->getAst(); // @todo: It would be good to actively remove 'config' from AST and array here // to prevent people from using the unmerged variant. The same // is already done for the determined PAGE 'config' below. This works, but // is currently blocked by functional tests that assert details? // Also, we need to still cache with full 'config' to handle multiple types. $setupRawConfigAst = $setupAst->getChildByName('config'); $frontendTypoScript->setSetupTree($setupAst); $frontendTypoScript->setSetupArray($setupAst->toArray()); // Write cache entry for AST and its array representation. $typoScriptCache?->set( $setupTypoScriptCacheIdentifier, 'return unserialize(\'' . addcslashes(serialize(['ast' => $setupAst, 'array' => $setupAst->toArray()]), '\'\\') . '\');' ); } $setupAst = $frontendTypoScript->getSetupTree(); $rawSetupPageNodeFromType = null; $pageNodeFoundByType = false; foreach ($setupAst->getNextChild() as $potentialPageNode) { // Find the PAGE object that matches given type/typeNum if ($potentialPageNode->getValue() === 'PAGE') { // @todo: We could potentially remove *all* PAGE objects from setup here. This prevents people // from accessing other ones than the determined one in $frontendTypoScript->getSetupArray(). $typeNumChild = $potentialPageNode->getChildByName('typeNum'); if ($typeNumChild && $type === $typeNumChild->getValue()) { $rawSetupPageNodeFromType = $potentialPageNode; $pageNodeFoundByType = true; break; } if (!$typeNumChild && $type === '0') { // The first PAGE node that has no typeNum is considered '0' automatically. $rawSetupPageNodeFromType = $potentialPageNode; $pageNodeFoundByType = true; break; } } } if (!$pageNodeFoundByType) { $rawSetupPageNodeFromType = new RootNode(); } $setupPageAst = new RootNode(); foreach ($rawSetupPageNodeFromType->getNextChild() as $child) { $setupPageAst->addChild($child); } if (!$gotSetupConfigFromCache) { // If we did not get merged 'config.' from cache above, create it now and cache it. $mergedSetupConfigAst = (new SetupConfigMerger())->merge($setupRawConfigAst, $setupPageAst->getChildByName('config')); if ($mergedSetupConfigAst->getChildByName('absRefPrefix') === null) { // Make sure config.absRefPrefix is set, fallback to 'auto'. $absRefPrefixNode = new ChildNode('absRefPrefix'); $absRefPrefixNode->setValue('auto'); $mergedSetupConfigAst->addChild($absRefPrefixNode); } if ($mergedSetupConfigAst->getChildByName('doctype') === null) { // Make sure config.doctype is set, fallback to 'html5'. $doctypeNode = new ChildNode('doctype'); $doctypeNode->setValue('html5'); $mergedSetupConfigAst->addChild($doctypeNode); } if ($request) { // Dispatch ModifyTypoScriptConfigEvent before config is cached and if Request is given. $mergedSetupConfigAst = $this->eventDispatcher ->dispatch(new ModifyTypoScriptConfigEvent($request, $setupAst, $mergedSetupConfigAst))->getConfigTree(); } $frontendTypoScript->setConfigTree($mergedSetupConfigAst); $setupConfigArray = $mergedSetupConfigAst->toArray(); $frontendTypoScript->setConfigArray($setupConfigArray); $typoScriptCache?->set( $setupConfigTypoScriptCacheIdentifier, 'return unserialize(\'' . addcslashes(serialize(['ast' => $mergedSetupConfigAst, 'array' => $setupConfigArray]), '\'\\') . '\');' ); } if ($pageNodeFoundByType) { // Remove "page.config" to prevent people from working with the not merged variant. // We do *not* set page if it could not be determined (important for hasPage() later // to return an early "no PAGE for type found" Response. $setupPageAst->removeChildByName('config'); $frontendTypoScript->setPageTree($setupPageAst); $frontendTypoScript->setPageArray($setupPageAst->toArray()); } } return $frontendTypoScript; } }