From 0fa6606fbab96e80a5f0337f4c42610409286699 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 10 Sep 2026 11:05:13 -0500 Subject: [PATCH 01/19] =?UTF-8?q?Adds=20an=20HTML=20parser=20+=20template?= =?UTF-8?q?=20instrumentation=20to=20Antlers=20=F0=9F=98=AE=E2=80=8D?= =?UTF-8?q?=F0=9F=92=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/antlers.php | 11 + src/Providers/ViewServiceProvider.php | 5 + src/Tags/ComponentProxy.php | 31 +- .../Language/Analyzers/Html/AntlersNode.php | 66 + .../Antlers/Language/Analyzers/Html/Arena.php | 375 ++++++ .../Concerns/AdoptsFormattingElements.php | 380 ++++++ .../Html/Concerns/AnchorsSourceNodes.php | 182 +++ .../Html/Concerns/BuildsDocumentGraph.php | 1175 +++++++++++++++++ .../Html/Concerns/ClosesImpliedElements.php | 388 ++++++ .../Html/Concerns/ParsesForeignContent.php | 202 +++ .../Html/Concerns/RecoversTables.php | 558 ++++++++ .../Analyzers/Html/Concerns/ScansMarkup.php | 602 +++++++++ .../Language/Analyzers/Html/Container.php | 372 ++++++ .../Language/Analyzers/Html/Document.php | 528 ++++++++ .../Language/Analyzers/Html/Element.php | 587 ++++++++ .../Antlers/Language/Analyzers/Html/Node.php | 301 +++++ .../Language/Analyzers/Html/NodeSequence.php | 198 +++ .../Language/Analyzers/Html/OpaqueNode.php | 69 + .../Language/Analyzers/Html/Region.php | 229 ++++ .../Language/Analyzers/Html/SourceAnchor.php | 67 + .../Language/Analyzers/Html/TableMode.php | 16 + .../Antlers/Language/Analyzers/Html/Text.php | 45 + .../Language/Analyzers/Html/TextEdge.php | 11 + src/View/Antlers/Language/Facades/Runtime.php | 8 +- .../Antlers/Language/Nodes/AbstractNode.php | 6 + .../Antlers/Language/Nodes/AntlersNode.php | 123 ++ .../Nodes/Parameters/ParameterNode.php | 5 + .../Language/Parser/ComponentCompiler.php | 30 +- .../Concerns/CompilesBladeComponents.php | 24 +- .../Language/Parser/DocumentParser.php | 146 +- .../Language/Runtime/ConditionProcessor.php | 4 +- .../Language/Runtime/NodeProcessor.php | 96 +- .../Language/Runtime/RuntimeConfiguration.php | 47 + .../Language/Runtime/RuntimeParser.php | 216 ++- .../Tracing/ProcessorAwareTracerContract.php | 12 + .../Language/Runtime/Tracing/TraceManager.php | 347 ++++- .../Language/Utilities/CharacterOffsets.php | 66 + .../Html/Concerns/QueriesElementBasics.php | 39 + src/View/Html/ElementCollection.php | 117 ++ src/View/Html/MissingAttributeException.php | 79 ++ .../Antlers/ComponentSourceMap.php | 115 ++ .../Antlers/ContextScanner.php | 402 ++++++ .../Antlers/ElementInspector.php | 478 +++++++ .../Instrumentation/Antlers/RegionExtents.php | 57 + .../Antlers/TemplateAnalyzer.php | 209 +++ .../Instrumentation/ComponentPrefixes.php | 50 + .../Concerns/ConfiguresInstrumentation.php | 177 +++ .../Concerns/EmitsInstrumentation.php | 319 +++++ .../Concerns/MemoizesResults.php | 71 + src/View/Instrumentation/HtmlContext.php | 223 ++++ .../Instrumentation/HtmlInstrumentation.php | 321 +++++ src/View/Instrumentation/HtmlSpec.php | 330 +++++ .../InstrumentationManager.php | 300 +++++ .../Instrumentation/InstrumentationState.php | 102 ++ src/View/Instrumentation/Span.php | 84 ++ src/View/Instrumentation/TemplateRegion.php | 41 + src/View/Instrumentation/TracerContract.php | 13 + 57 files changed, 10968 insertions(+), 87 deletions(-) create mode 100644 src/View/Antlers/Language/Analyzers/Html/AntlersNode.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Arena.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/ClosesImpliedElements.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/ParsesForeignContent.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/RecoversTables.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Concerns/ScansMarkup.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Container.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Document.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Element.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Node.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/NodeSequence.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/OpaqueNode.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Region.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/SourceAnchor.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/TableMode.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/Text.php create mode 100644 src/View/Antlers/Language/Analyzers/Html/TextEdge.php create mode 100644 src/View/Antlers/Language/Runtime/Tracing/ProcessorAwareTracerContract.php create mode 100644 src/View/Html/Concerns/QueriesElementBasics.php create mode 100644 src/View/Html/ElementCollection.php create mode 100644 src/View/Html/MissingAttributeException.php create mode 100644 src/View/Instrumentation/Antlers/ComponentSourceMap.php create mode 100644 src/View/Instrumentation/Antlers/ContextScanner.php create mode 100644 src/View/Instrumentation/Antlers/ElementInspector.php create mode 100644 src/View/Instrumentation/Antlers/RegionExtents.php create mode 100644 src/View/Instrumentation/Antlers/TemplateAnalyzer.php create mode 100644 src/View/Instrumentation/ComponentPrefixes.php create mode 100644 src/View/Instrumentation/Concerns/ConfiguresInstrumentation.php create mode 100644 src/View/Instrumentation/Concerns/EmitsInstrumentation.php create mode 100644 src/View/Instrumentation/Concerns/MemoizesResults.php create mode 100644 src/View/Instrumentation/HtmlContext.php create mode 100644 src/View/Instrumentation/HtmlInstrumentation.php create mode 100644 src/View/Instrumentation/HtmlSpec.php create mode 100644 src/View/Instrumentation/InstrumentationManager.php create mode 100644 src/View/Instrumentation/InstrumentationState.php create mode 100644 src/View/Instrumentation/Span.php create mode 100644 src/View/Instrumentation/TemplateRegion.php create mode 100644 src/View/Instrumentation/TracerContract.php diff --git a/config/antlers.php b/config/antlers.php index b2cdd86f7bb..7176b9c1830 100644 --- a/config/antlers.php +++ b/config/antlers.php @@ -15,6 +15,17 @@ 'debugbar' => env('STATAMIC_ANTLERS_DEBUGBAR', true), + 'tracing' => env('STATAMIC_ANTLERS_TRACING', false), + + 'tracers' => [], + + 'instrumentation' => [ + 'enabled' => env('STATAMIC_ANTLERS_INSTRUMENTATION', false), + 'comments' => true, + 'prefix' => 'antlers', + 'attributes' => [], + ], + /* |-------------------------------------------------------------------------- | Guarded Variables diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index 2ebcba95062..a6b9dd50f35 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -27,6 +27,7 @@ use Statamic\View\Cascade; use Statamic\View\Debugbar\AntlersProfiler\PerformanceCollector; use Statamic\View\Debugbar\AntlersProfiler\PerformanceTracer; +use Statamic\View\Instrumentation\InstrumentationManager; use Statamic\View\Interop\Stacks; class ViewServiceProvider extends ServiceProvider @@ -84,6 +85,8 @@ private function registerAntlers() return new PerformanceTracer(); }); + $this->app->singleton(InstrumentationManager::class); + $this->app->bind(ParserContract::class, function ($app) { /** @var RuntimeParser $parser */ $parser = $app->make(RuntimeParser::class)->cascade($app[Cascade::class]); @@ -159,6 +162,8 @@ private function registerAntlers() $runtimeConfig->traceManager->registerTracer(app(PerformanceTracer::class)); } + $app->make(InstrumentationManager::class)->applyTo($runtimeConfig); + $parser->isolateRuntimes(GlobalRuntimeState::$requiresRuntimeIsolation) ->setRuntimeConfiguration($runtimeConfig); diff --git a/src/Tags/ComponentProxy.php b/src/Tags/ComponentProxy.php index 5a18593cc87..9c36c0faf6a 100644 --- a/src/Tags/ComponentProxy.php +++ b/src/Tags/ComponentProxy.php @@ -8,6 +8,7 @@ use Illuminate\View\Compilers\ComponentTagCompiler; use Illuminate\View\ComponentAttributeBag; use ReflectionClass; +use Statamic\View\Instrumentation\InstrumentationState; use Throwable; class ComponentProxy extends Tags @@ -26,11 +27,15 @@ private function makeComponentTagCompiler(): ComponentTagCompiler public function index() { $___obLevel = ob_get_level(); + $__env = null; + $renderIncremented = false; + $componentStackPushed = false; try { $__env = $this->context['__env'] ?? view(); $__env->incrementRender(); + $renderIncremented = true; $componentName = $this->params['component_name___']; $tagCompiler = $this->makeComponentTagCompiler(); @@ -82,22 +87,38 @@ public function index() ]); self::$componentStack[] = [$component, $contextData]; + $componentStackPushed = true; - echo $this->parse($contextData); + echo InstrumentationState::whileParsingComponentContent( + fn () => $this->parse($contextData) + ); } - $result = $__env->renderComponent(); + $result = InstrumentationState::whileRenderingComponentView( + fn () => $__env->renderComponent() + ); $__env->decrementRender(); + $renderIncremented = false; $__env->flushStateIfDoneRendering(); - if ($this->content) { + if ($componentStackPushed) { array_pop(self::$componentStack); + $componentStackPushed = false; } return ltrim($result); } catch (Throwable $e) { $this->handleViewException($e, $___obLevel); + } finally { + if ($componentStackPushed) { + array_pop(self::$componentStack); + } + + if ($renderIncremented && $__env !== null) { + $__env->decrementRender(); + $__env->flushStateIfDoneRendering(); + } } } @@ -114,7 +135,9 @@ public function componentSlot() $__env->slot($slot, null, $context); - echo $this->parse($contextData); + echo InstrumentationState::whileParsingComponentContent( + fn () => $this->parse($contextData) + ); $__env->endSlot(); } catch (Throwable $e) { diff --git a/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php b/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php new file mode 100644 index 00000000000..ff4844019be --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php @@ -0,0 +1,66 @@ +node = $node; + $this->source = $source; + } + + public static function fromArena(Document $document, $id) + { + $arena = $document->arena(); + $node = new self($document, $arena->antlersNode[$id], $arena->value[$id]); + $node->arenaId = $id; + + return $node; + } + + public function antlersNode() + { + return $this->node; + } + + public function source($source = null) + { + if (func_num_args() === 0) { + return $this->source; + } + + if (isset($this->document->arena()->embeddedOwner[$this->arenaId])) { + throw new \LogicException('Antlers regions embedded in HTML markup are printed by their owning node and cannot be replaced independently.'); + } + + $parent = $this->parent(); + + if ($parent !== null && ! $parent->hasChild($this)) { + throw new \LogicException('Antlers regions embedded in HTML markup are printed by their owning node and cannot be replaced independently.'); + } + + $this->source = (string) $source; + + if ($this->arenaId !== null) { + $this->document->arena()->value[$this->arenaId] = $this->source; + } + $this->touchDocument(); + + return $this; + } + + protected function render() + { + return $this->source; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Arena.php b/src/View/Antlers/Language/Analyzers/Html/Arena.php new file mode 100644 index 00000000000..e96d88ec161 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Arena.php @@ -0,0 +1,375 @@ + */ + public $parents = [null]; + + /** @var array */ + public $firstChildren = [null]; + + /** @var array */ + public $lastChildren = [null]; + + /** @var array */ + public $previousSiblings = [null]; + + /** @var array */ + public $nextSiblings = [null]; + + /** @var array */ + public $name = [0 => null]; + + /** @var string[] */ + public $value = ['']; + + /** @var array */ + public $closing = [0 => '']; + + /** @var array */ + public $opaqueType = []; + + /** @var array */ + public $antlersNode = []; + + /** @var array */ + public $embedded = []; + + /** @var array */ + public $embeddedOwner = []; + + /** @var array target id => anchor id */ + public $sourceAnchor = []; + + /** @var array anchor id => target id */ + public $anchorTarget = []; + + public function allocate($kind) + { + $id = count($this->kinds); + $this->kinds[] = $kind; + $this->parents[] = null; + $this->firstChildren[] = null; + $this->lastChildren[] = null; + $this->previousSiblings[] = null; + $this->nextSiblings[] = null; + $this->value[] = ''; + + return $id; + } + + public function element($name, $opening, $selfClosing = false, $namespace = 'html', $synthetic = false) + { + $flags = self::ELEMENT; + + if ($selfClosing) { + $flags |= self::SELF_CLOSING; + } + + if ($synthetic) { + $flags |= self::SYNTHETIC; + } + + if ($namespace === 'svg') { + $flags |= self::SVG; + } + + if ($namespace === 'mathml') { + $flags |= self::MATHML; + } + + $id = count($this->kinds); + $this->kinds[] = $flags; + $this->parents[] = null; + $this->firstChildren[] = null; + $this->lastChildren[] = null; + $this->previousSiblings[] = null; + $this->nextSiblings[] = null; + $this->name[$id] = $name; + $this->value[] = $opening; + $this->closing[$id] = ''; + + return $id; + } + + public function text($value) + { + $id = count($this->kinds); + $this->kinds[] = self::TEXT; + $this->parents[] = null; + $this->firstChildren[] = null; + $this->lastChildren[] = null; + $this->previousSiblings[] = null; + $this->nextSiblings[] = null; + $this->value[] = $value; + + return $id; + } + + public function antlers(LanguageAntlersNode $node, $source) + { + $id = $this->allocate(self::ANTLERS); + $this->antlersNode[$id] = $node; + $this->value[$id] = $source; + + return $id; + } + + public function opaque($source, $type) + { + $id = $this->allocate(self::OPAQUE); + $this->value[$id] = $source; + $this->opaqueType[$id] = $type; + + return $id; + } + + public function anchor($target) + { + $id = $this->allocate(self::SOURCE_ANCHOR); + $this->anchorTarget[$id] = $target; + $this->sourceAnchor[$target] = $id; + + return $id; + } + + public function kind($id) + { + return $this->kinds[$id]; + } + + public function parent($id) + { + return $this->parents[$id]; + } + + public function setParent($id, $parent) + { + $this->parents[$id] = $parent; + } + + public function firstChild($id) + { + return $this->firstChildren[$id]; + } + + public function lastChild($id) + { + return $this->lastChildren[$id]; + } + + public function previous($id) + { + return $this->previousSiblings[$id]; + } + + public function next($id) + { + return $this->nextSiblings[$id]; + } + + public function anchorExisting($target) + { + $parent = $this->parent($target); + + if ($parent === null) { + return $this->anchor($target); + } + + $previous = $this->previous($target); + $next = $this->next($target); + $anchor = $this->anchor($target); + $this->setParent($anchor, $parent); + $this->setPrevious($anchor, $previous); + $this->setNext($anchor, $next); + + if ($previous === null) { + $this->setFirstChild($parent, $anchor); + } else { + $this->setNext($previous, $anchor); + } + + if ($next === null) { + $this->setLastChild($parent, $anchor); + } else { + $this->setPrevious($next, $anchor); + } + + $this->setParent($target, null); + $this->previousSiblings[$target] = null; + $this->nextSiblings[$target] = null; + + return $anchor; + } + + public function append($parent, $node) + { + $last = $this->lastChildren[$parent]; + $this->parents[$node] = $parent; + $this->previousSiblings[$node] = $last; + $this->nextSiblings[$node] = null; + + if ($last === null) { + $this->firstChildren[$parent] = $node; + } else { + $this->nextSiblings[$last] = $node; + } + + $this->lastChildren[$parent] = $node; + + return $node; + } + + public function insertBefore($reference, $node) + { + $parent = $this->parent($reference); + $previous = $this->previous($reference); + $this->setParent($node, $parent); + $this->previousSiblings[$node] = $previous; + $this->nextSiblings[$node] = $reference; + $this->setPrevious($reference, $node); + + if ($previous === null) { + $this->setFirstChild($parent, $node); + } else { + $this->setNext($previous, $node); + } + + return $node; + } + + public function insertAfter($reference, $node) + { + $parent = $this->parent($reference); + $next = $this->next($reference); + $this->setParent($node, $parent); + $this->previousSiblings[$node] = $reference; + $this->nextSiblings[$node] = $next; + $this->setNext($reference, $node); + + if ($next === null) { + $this->setLastChild($parent, $node); + } else { + $this->setPrevious($next, $node); + } + + return $node; + } + + public function detach($node) + { + $parent = $this->parent($node); + + if ($parent === null) { + return $node; + } + + $previous = $this->previous($node); + $next = $this->next($node); + + if ($previous === null) { + $this->setFirstChild($parent, $next); + } else { + $this->setNext($previous, $next); + } + + if ($next === null) { + $this->setLastChild($parent, $previous); + } else { + $this->setPrevious($next, $previous); + } + + $this->setParent($node, null); + $this->previousSiblings[$node] = null; + $this->nextSiblings[$node] = null; + + return $node; + } + + public function children($parent) + { + $children = []; + $seen = []; + + for ($child = $this->firstChild($parent); $child !== null; $child = $this->next($child)) { + if (isset($seen[$child])) { + throw new \LogicException('Cycle detected in HTML arena siblings for parent '.$parent.'.'); + } + + $seen[$child] = true; + $children[] = $child; + } + + return $children; + } + + public function hasChild($parent, $node) + { + return $this->parent($node) === $parent && ! isset($this->embeddedOwner[$node]); + } + + public function namespace($id) + { + $type = $this->kind($id); + + if ($type & self::SVG) { + return 'svg'; + } + + if ($type & self::MATHML) { + return 'mathml'; + } + + return 'html'; + } + + protected function setFirstChild($id, $child) + { + $this->firstChildren[$id] = $child; + } + + protected function setLastChild($id, $child) + { + $this->lastChildren[$id] = $child; + } + + protected function setPrevious($id, $previous) + { + $this->previousSiblings[$id] = $previous; + } + + protected function setNext($id, $next) + { + $this->nextSiblings[$id] = $next; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php new file mode 100644 index 00000000000..fefaea3627d --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php @@ -0,0 +1,380 @@ +activeFormattingIndex($name); + + if ($activeIndex === null) { + return false; + } + + $formatting = $this->activeFormatting[$activeIndex]; + $match = array_search($formatting, $this->stack, true); + + if ($match === false) { + array_splice($this->activeFormatting, $activeIndex, 1); + + if ($closingMarkup !== null) { + $this->appendOpaqueMarkup($start, $end, $closingMarkup); + } + + return true; + } + + if (! $this->stackIndexIsInScope($match, self::$scopeBoundaries)) { + if ($closingMarkup !== null) { + $this->appendOpaqueMarkup($start, $end, $closingMarkup); + } + + return true; + } + + if ($this->adoptFormattingAroundBlocks($match, $formatting, $closingMarkup, $start, $end)) { + return true; + } + + if ($closingMarkup !== null) { + $this->attachEmbeddedRegions($formatting, $start, $end); + $this->setElementClosingMarkup($formatting, $closingMarkup); + } + + $this->sliceOpenStack($match); + + if ($activeIndex !== null) { + array_splice($this->activeFormatting, $activeIndex, 1); + } + + return true; + } + + protected function adoptFormattingAroundBlocks($match, $formatting, $closingMarkup, $start, $end) + { + $transformed = false; + $closingOwner = null; + + for ($outer = 0; $outer < self::MAX_FORMATTING_ADOPTION_PASSES; $outer++) { + $activeIndex = $this->activeFormattingIndex($this->arena->name[$formatting]); + + if ($activeIndex === null) { + break; + } + + $formatting = $this->activeFormatting[$activeIndex]; + $match = array_search($formatting, $this->stack, true); + + if ($match === false) { + array_splice($this->activeFormatting, $activeIndex, 1); + break; + } + + $furthestIndex = $this->furthestFormattingBoundaryIndex($match); + + if ($furthestIndex === null) { + if (! $transformed) { + return false; + } + + $closingOwner = $formatting; + $this->sliceOpenStack($match); + array_splice($this->activeFormatting, $activeIndex, 1); + break; + } + + $transformed = true; + $furthest = $this->stack[$furthestIndex]; + $commonAncestor = 0; + + if ($match !== 0) { + $commonAncestor = $this->stack[$match - 1]; + } + + // The bookmark is the replacement's position in active formatting; + // it must follow removals and reconstructed ancestors below. + $bookmark = $activeIndex; + $lastNode = $furthest; + $inner = 0; + + // Rebuild the ancestors between the block and the formatting element. + for ($index = $furthestIndex - 1; $index > $match; $index--) { + $inner++; + $node = $this->stack[$index]; + $nodeActiveIndex = array_search($node, $this->activeFormatting, true); + + if ($inner > self::MAX_FORMATTING_INNER_RECONSTRUCTIONS && $nodeActiveIndex !== false) { + array_splice($this->activeFormatting, $nodeActiveIndex, 1); + $nodeActiveIndex = false; + } + + if ($nodeActiveIndex === false) { + array_splice($this->stack, $index, 1); + $furthestIndex--; + + continue; + } + + $copy = $this->reconstructedCopy($node); + $this->activeFormatting[$nodeActiveIndex] = $copy; + $this->stack[$index] = $copy; + + if ($lastNode === $furthest) { + $bookmark = $nodeActiveIndex + 1; + } + + $this->anchorArenaNodeForMove($lastNode); + $this->arena->append($copy, $lastNode); + $lastNode = $copy; + } + + // Move the rebuilt subtree, retaining its authored rendering position. + $this->anchorArenaNodeForMove($lastNode); + + if ($this->arena->parent($formatting) === $commonAncestor) { + $this->arena->insertAfter($formatting, $lastNode); + } elseif ($this->isTableFosterParent($commonAncestor)) { + $tableIndex = $this->nearestTableIndex(); + $table = null; + + if ($tableIndex !== null) { + $table = $this->stack[$tableIndex]; + } + + if ($table !== null && $this->arena->parent($table) !== null) { + $this->arena->insertBefore($table, $lastNode); + } else { + $this->arena->append($commonAncestor, $lastNode); + } + } else { + $this->arena->append($commonAncestor, $lastNode); + } + + // Wrap the block's children and replace the old formatting entry in + // both lists. Keep these mutations ordered as their indices change. + $newFormatting = $this->reconstructedCopy($formatting); + + foreach ($this->arena->children($furthest) as $child) { + $this->anchorArenaNodeForMove($child); + $this->arena->append($newFormatting, $child); + } + + $this->arena->append($furthest, $newFormatting); + $closingOwner = $newFormatting; + + $formattingActiveIndex = array_search($formatting, $this->activeFormatting, true); + + if ($formattingActiveIndex !== false) { + array_splice($this->activeFormatting, $formattingActiveIndex, 1); + + if ($formattingActiveIndex < $bookmark) { + $bookmark--; + } + } + + array_splice($this->activeFormatting, $bookmark, 0, [$newFormatting]); + $formattingStackIndex = array_search($formatting, $this->stack, true); + + if ($formattingStackIndex !== false) { + array_splice($this->stack, $formattingStackIndex, 1); + } + + $furthestStackIndex = array_search($furthest, $this->stack, true); + + if ($furthestStackIndex === false) { + $this->stack[] = $newFormatting; + } else { + array_splice($this->stack, $furthestStackIndex + 1, 0, [$newFormatting]); + } + } + + if (! $transformed || $closingOwner === null) { + return false; + } + + if ($closingMarkup !== null) { + if ($outer === self::MAX_FORMATTING_ADOPTION_PASSES) { + $this->appendOpaqueMarkup($start, $end, $closingMarkup); + } else { + $this->setElementClosingMarkup($closingOwner, $closingMarkup); + $this->attachEmbeddedRegions($closingOwner, $start, $end); + } + } + + return true; + } + + protected function reconstructActiveFormatting() + { + $count = count($this->activeFormatting); + + if ($count === 0 + || $this->activeFormatting[$count - 1] === null + || in_array($this->activeFormatting[$count - 1], $this->stack, true)) { + return; + } + + $index = $count - 1; + + while ($index > 0 + && $this->activeFormatting[$index - 1] !== null + && ! in_array($this->activeFormatting[$index - 1], $this->stack, true)) { + $index--; + } + + for (; $index < $count; $index++) { + $copy = $this->reconstructedCopy($this->activeFormatting[$index]); + $container = $this->currentContainer(); + + if ($this->shouldFosterContent()) { + $this->fosterNode($copy, $container); + } else { + $this->arena->append($container, $copy); + } + + $this->stack[] = $copy; + $this->activeFormatting[$index] = $copy; + } + } + + protected function activeFormattingIndex($name) + { + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + if ($this->activeFormatting[$index] === null) { + return null; + } + + if (strcasecmp((string) $this->arena->name[$this->activeFormatting[$index]], $name) === 0) { + return $index; + } + } + + return null; + } + + protected function stackIndexIsInScope($targetIndex, array $boundaries) + { + for ($index = count($this->stack) - 1; $index >= 0; $index--) { + if ($index === $targetIndex) { + return true; + } + + $element = $this->stack[$index]; + + if ($this->isForeignElement($element)) { + if ($this->foreignElementEndsScope($element, $boundaries)) { + return false; + } + + continue; + } + + if (isset($boundaries[strtolower((string) $this->arena->name[$element])])) { + return false; + } + } + + return false; + } + + protected function furthestFormattingBoundaryIndex($formattingIndex) + { + for ($index = $formattingIndex + 1, $count = count($this->stack); $index < $count; $index++) { + $candidate = $this->stack[$index]; + $name = strtolower((string) $this->arena->name[$candidate]); + + if ($this->isFormattingBoundary($candidate, $name)) { + return $index; + } + } + + return null; + } + + protected function isFormattingBoundary($candidate, $name) + { + if ($this->isForeignElement($candidate)) { + return $this->isForeignScopeBoundary($candidate); + } + + return isset(self::$formattingBreakStarts[$name]); + } + + protected function pushActiveFormattingElement($element) + { + $equivalent = []; + + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + $candidate = $this->activeFormatting[$index]; + + if ($candidate === null) { + break; + } + + if ($this->formattingElementsAreEquivalent($candidate, $element)) { + $equivalent[] = $index; + } + } + + if (count($equivalent) >= 3) { + array_splice($this->activeFormatting, $equivalent[count($equivalent) - 1], 1); + } + + $this->activeFormatting[] = $element; + } + + protected function formattingElementsAreEquivalent($left, $right) + { + if ($this->arena->name[$left] !== $this->arena->name[$right] + || $this->arena->namespace($left) !== $this->arena->namespace($right)) { + return false; + } + + $leftMarkup = $this->arena->value[$left]; + $rightMarkup = $this->arena->value[$right]; + + if (strpos($leftMarkup, '{{') !== false || strpos($rightMarkup, '{{') !== false) { + return $leftMarkup === $rightMarkup; + } + + $leftAttributes = $this->handle($left)->attributes(); + $rightAttributes = $this->handle($right)->attributes(); + ksort($leftAttributes); + ksort($rightAttributes); + + return $leftAttributes === $rightAttributes; + } + + protected function sliceOpenStack($length) + { + $this->stack = array_slice($this->stack, 0, $length); + } + + protected function clearActiveFormattingToLastMarker() + { + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + if ($this->activeFormatting[$index] === null) { + $this->activeFormatting = array_slice($this->activeFormatting, 0, $index); + + return; + } + } + } + + protected function reconstructedCopy($element) + { + return $this->arena->element( + $this->arena->name[$element], + $this->arena->value[$element], + false, + $this->arena->namespace($element), + true + ); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php new file mode 100644 index 00000000000..2129bf6dc95 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php @@ -0,0 +1,182 @@ +arena->parent($node); $parent !== null; $parent = $this->arena->parent($parent)) { + if (isset($seen[$parent])) { + throw new \LogicException('Cycle detected while resolving an HTML arena ancestor.'); + } + + $seen[$parent] = true; + + if ($parent === $ancestor) { + return true; + } + } + + return false; + } + + protected function arenaNodeHasSourceAnchoredAncestor($node) + { + return $this->arenaNodeAncestorSourceAnchor($node) !== null; + } + + protected function arenaNodeAncestorSourceAnchor($node) + { + $seen = []; + + for ($parent = $this->arena->parent($node); $parent !== null; $parent = $this->arena->parent($parent)) { + if (isset($seen[$parent])) { + throw new \LogicException('Cycle detected while resolving an HTML arena source anchor.'); + } + + $seen[$parent] = true; + + if (isset($this->arena->sourceAnchor[$parent])) { + return $this->arena->sourceAnchor[$parent]; + } + } + + return null; + } + + protected function anchorRecoveredSourceNode( + $node, + $container, + $sourceTable, + $sourceTableTail, + $previous = null, + $sourcePositionAnchor = null + ) { + $containerIsSynthetic = (bool) ($this->arena->kind($container) & Arena::SYNTHETIC); + $sourcePositionAnchor = $sourcePositionAnchor + ?? ($this->arena->sourceAnchor[$container] ?? null); + + if ($sourcePositionAnchor === null && $sourceTable === null && ! $containerIsSynthetic) { + return; + } + + $sourcePositionTarget = null; + + if ($sourcePositionAnchor !== null) { + $sourcePositionTarget = $this->arena->anchorTarget[$sourcePositionAnchor] ?? null; + } + + // Continue an existing source-position anchor without anchoring the same subtree twice. + if ($sourcePositionTarget !== null) { + $ancestorSourceAnchor = $this->arenaNodeAncestorSourceAnchor($node); + $ancestorAlreadyInsideSourceTarget = $ancestorSourceAnchor !== null + && $this->arenaNodeIsDescendantOf($ancestorSourceAnchor, $sourcePositionTarget); + $nodeIsInsideSourceTarget = $this->arenaNodeIsDescendantOf($node, $sourcePositionTarget); + + if ($nodeIsInsideSourceTarget) { + if ($this->anchorAfterSourcePositionTail($node, $sourcePositionTarget)) { + return; + } + } + + if (! $nodeIsInsideSourceTarget + && ! $ancestorAlreadyInsideSourceTarget) { + $anchor = $this->arena->anchor($node); + $this->arena->insertAfter($sourcePositionAnchor, $anchor); + + return; + } + } + + // Content recovered outside a table still renders at its authored position inside it. + if ($sourceTable !== null + && ! $this->arenaNodeIsDescendantOf($node, $sourceTable) + && ! $this->arenaNodeHasSourceAnchoredAncestor($node)) { + $anchor = $this->arena->anchor($node); + + if ($sourceTableTail !== null && $this->arena->parent($sourceTableTail) === $sourceTable) { + $this->arena->insertAfter($sourceTableTail, $anchor); + } else { + $this->arena->append($sourceTable, $anchor); + } + + return; + } + + if ($previous === null) { + $previous = $this->arena->previous($node); + } + + // A synthetic container has no authored opening tag; follow its previous child's anchor. + if ($containerIsSynthetic + && $previous !== null + && isset($this->arena->sourceAnchor[$previous])) { + $previousAnchor = $this->arena->sourceAnchor[$previous]; + $anchorParent = $this->arena->parent($previousAnchor); + + if ($anchorParent !== null + && ($this->arena->kind($anchorParent) & Arena::KIND_MASK) === Arena::ELEMENT + && $this->arena->closing[$anchorParent] !== '') { + return; + } + + $anchor = $this->arena->anchor($node); + $this->arena->insertAfter($previousAnchor, $anchor); + } + } + + protected function anchorAfterSourcePositionTail($node, $sourceContainer) + { + $sourcePositionAnchor = $this->arena->sourceAnchor[$sourceContainer] ?? null; + + if ($sourcePositionAnchor === null) { + return false; + } + + $anchorParent = $this->arena->parent($sourcePositionAnchor); + + if ($anchorParent === null) { + return false; + } + + $sourceTail = $this->arena->lastChild($anchorParent); + + if ($sourceTail === null + || $sourceTail === $sourcePositionAnchor + || ($this->arena->kind($sourceTail) & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + return false; + } + + $this->arena->insertAfter($sourceTail, $this->arena->anchor($node)); + + return true; + } + + protected function anchorArenaNodeForMove($node) + { + if ($this->arena->parent($node) === null) { + return; + } + + if (! isset($this->arena->sourceAnchor[$node])) { + $this->arena->anchorExisting($node); + + return; + } + + $this->arena->detach($node); + } + + protected function markDirtyFromContainer() + { + $this->markDirty(); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php new file mode 100644 index 00000000000..6f89be98844 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php @@ -0,0 +1,1175 @@ +source, '<', $position); + + if ($nextMarkup === false) { + $nextMarkup = $sourceLength; + } + + $nextAntlers = $sourceLength; + + if (! empty($this->regionStarts)) { + $regionStart = $this->nextRegionStart($position); + + if ($regionStart !== null) { + $nextAntlers = $regionStart; + } + } + + return min($nextMarkup, $nextAntlers); + } + + protected function startsBogusClosingTag($start, $next) + { + if ($next !== '/') { + return false; + } + + $nameStart = $this->source[$start + 2] ?? null; + + if ($nameStart === '>') { + return false; + } + + if (HtmlSpec::isAsciiAlpha($nameStart)) { + return false; + } + + return ! isset($this->regionEnds[$start + 2]); + } + + protected function startsLiteralLessThan($start, $next) + { + if ($next === null || HtmlSpec::isAsciiAlpha($next)) { + return false; + } + + if (in_array($next, ['!', '?', '/'], true)) { + return false; + } + + return ! isset($this->regionEnds[$start + 1]); + } + + protected function endTagContext($name) + { + $processAsHtml = ! $this->currentContainerIsForeign(); + $foreignMatch = null; + + if (! $processAsHtml && in_array($name, ['br', 'p'], true)) { + $this->exitForeignContent(); + $processAsHtml = true; + } + + if ($processAsHtml) { + return [$processAsHtml, $foreignMatch]; + } + + for ($index = count($this->stack) - 1; $index >= 0; $index--) { + $element = $this->stack[$index]; + + if (! $this->isForeignElement($element)) { + return [true, null]; + } + + if (strcasecmp((string) $this->arena->name[$element], $name) === 0) { + return [false, $index]; + } + } + + return [false, null]; + } + + protected function closesCurrentTextElement($name) + { + if ($this->textElement === null) { + return false; + } + + return strcasecmp((string) $this->arena->name[$this->textElement], $name) === 0; + } + + protected function shouldCloseSelectBeforeEndTag($name) + { + if ($this->openSelect === null || ! $this->selectInTable) { + return false; + } + + if (! $this->isInHtmlSelectMode()) { + return false; + } + + return isset(self::$selectTableElements[$name]); + } + + protected function shouldIgnoreEndTagInSelect($name, $closesCurrentTextElement) + { + if ($this->openSelect === null || ! $this->isInHtmlSelectMode()) { + return false; + } + + if ($closesCurrentTextElement) { + return false; + } + + return ! in_array($name, ['option', 'optgroup', 'select', 'template'], true); + } + + protected function shouldCloseColgroupBeforeEndTag($name, $processAsHtml) + { + if (! $processAsHtml) { + return false; + } + + if ($this->tableMode() !== TableMode::ColumnGroup) { + return false; + } + + return ! in_array($name, ['col', 'colgroup', 'template'], true); + } + + protected function isHtmlFormEndTag($name, $processAsHtml) + { + if (! $processAsHtml || $name !== 'form') { + return false; + } + + return ! $this->hasOpenHtmlTemplate(); + } + + protected function consumeFormEndTag($start, $end, $markup) + { + if ($this->formElement === null) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return; + } + + $formIndex = array_search($this->formElement, $this->stack, true); + + if ($formIndex !== false && ! $this->stackIndexIsInScope($formIndex, self::$scopeBoundaries)) { + $this->formElement = null; + $this->appendOpaqueMarkup($start, $end, $markup); + + return; + } + + if ($formIndex !== false) { + $tableIndex = $this->nearestTableIndex(); + + if ($tableIndex === null || $formIndex > $tableIndex) { + $this->generateImpliedEndTags(); + $formIndex = array_search($this->formElement, $this->stack, true); + } + + if ($formIndex !== false) { + array_splice($this->stack, $formIndex, 1); + } + } + + $this->formElement = null; + $this->appendOpaqueMarkup($start, $end, $markup); + } + + protected function closeMatchedElement($match, $name, $markup, $start, $end) + { + $matchedElement = $this->stack[$match]; + + if (! empty($this->regionStarts)) { + $this->attachEmbeddedRegions($matchedElement, $start, $end, true); + } + + $this->setElementClosingMarkup($matchedElement, $markup); + + if ($matchedElement === $this->textElement) { + $this->textElement = null; + } + + $clearsFormatting = ! $this->isForeignElement($matchedElement) + && isset(self::$formattingMarkerElements[$this->arena->name[$matchedElement]]); + $this->sliceOpenStack($match); + + if ($clearsFormatting) { + $this->clearActiveFormattingToLastMarker(); + } + + if ($name === 'select') { + $this->openSelect = null; + $this->selectInTable = false; + } + + if ($name === 'template') { + unset($this->templateInsertionModes[$matchedElement]); + } + } + + protected function closeDynamicElement($start, $end, $markup) + { + for ($index = count($this->stack) - 1; $index >= 0; $index--) { + $element = $this->stack[$index]; + + if ($this->arena->name[$element] !== null) { + continue; + } + + $this->attachEmbeddedRegions($element, $start, $end, true); + $this->setElementClosingMarkup($element, $markup); + $this->sliceOpenStack($index); + + return true; + } + + return false; + } + + protected function consumeStartTagInSelectMode($name, $start, $end, $markup) + { + if ($this->selectInTable && isset(self::$selectTableElements[$name])) { + $this->closeOpenElement('select'); + + return false; + } + + if ($name === 'select') { + $this->closeOpenElement('select'); + $this->appendOpaqueMarkup($start, $end, $markup); + + return true; + } + + if (in_array($name, ['input', 'keygen', 'textarea'], true)) { + $this->closeOpenElement('select'); + + return false; + } + + if (in_array($name, ['option', 'optgroup', 'hr', 'script', 'template'], true)) { + return false; + } + + $this->appendOpaqueMarkup($start, $end, $markup); + + return true; + } + + protected function shouldExitForeignContentBeforeStart($container, $name, $markup) + { + if ($container === 0 || ! $this->isForeignElement($container)) { + return false; + } + + return $this->shouldExitForeignContent($name, $markup); + } + + protected function shouldIgnoreFrameStart($name, $isHtmlElement, $processStartAsHtml) + { + if (! $isHtmlElement || ! $processStartAsHtml) { + return false; + } + + return in_array($name, ['frame', 'frameset'], true); + } + + protected function initialFosterState($name, $namespace, $sourceContainer, $processStartAsHtml) + { + if ($processStartAsHtml && $name !== null) { + return $this->prepareTableStart($name); + } + + if ($sourceContainer !== 0 && $this->isForeignElement($sourceContainer)) { + return false; + } + + return $namespace !== 'html' && $this->shouldFosterContent(); + } + + protected function isFosteredHiddenInput($element, $name, $foster) + { + if (! $foster || $name !== 'input') { + return false; + } + + return strtolower((string) $this->handle($element)->attr('type')) === 'hidden'; + } + + protected function insertStartElement( + $element, + $sourceContainer, + $sourcePositionAnchor, + $sourceTable, + $sourceTableTail, + $foster + ) { + if ($foster) { + $this->fosterNode($element, $sourceContainer); + + return; + } + + $insertionContainer = $this->currentContainer(); + $this->arena->append($insertionContainer, $element); + + $this->anchorRecoveredSourceNode( + $element, + $insertionContainer, + $sourceTable, + $sourceTableTail, + $this->arena->previous($element), + $sourcePositionAnchor + ); + } + + protected function pushStartElement( + $element, + $name, + $namespace, + $selfClosing, + $selectInTable, + $tableForm + ) { + if ($tableForm || ! $this->shouldPushElement($name, $namespace, $selfClosing)) { + return; + } + + $this->stack[] = $element; + + if ($namespace !== 'html') { + return; + } + + if ($name !== null && isset(self::$formattingElements[$name])) { + $this->pushActiveFormattingElement($element); + } + + if ($name === 'table') { + $this->hasSeenTable = true; + } + + if ($name === 'template') { + $this->templateInsertionModes[$element] = TableMode::Template; + } + + if ($name === 'select') { + $this->openSelect = $element; + $this->selectInTable = $selectInTable; + } + + if (isset(self::$formattingMarkerElements[$name])) { + $this->activeFormatting[] = null; + } + + if ($name === 'script') { + $this->scriptEscape = self::SCRIPT_DATA; + } + + if ($this->startsTextElement($name)) { + $this->textElement = $element; + } + } + + protected function build(?array $nodes) + { + if ($nodes !== null) { + $this->indexAntlersByteRegions($nodes); + } + $this->regionCursor = 0; + $sourceLength = strlen($this->source); + $position = 0; + + while ($position < $sourceLength) { + if (isset($this->regionEnds[$position])) { + $position = $this->appendAntlers($position, $this->currentContainer()); + + continue; + } + + if ($this->textElement !== null && $this->arena->name[$this->textElement] === 'plaintext') { + $this->appendTextWithAntlers($position, $sourceLength, $this->currentContainer()); + $position = $sourceLength; + + continue; + } + + if ($this->textElement !== null && ! $this->isCurrentRawTextClosingTag($position)) { + $next = $this->nextRawTextBoundary($position); + $this->appendTextWithAntlers($position, $next, $this->currentContainer()); + $position = $next; + + continue; + } + + if ($this->source[$position] !== '<') { + $next = $this->nextTextBoundary($position, $sourceLength); + $this->appendParsedText(substr($this->source, $position, $next - $position)); + $position = $next; + + continue; + } + + $position = $this->consumeMarkup($position); + } + + $this->regionEnds = []; + $this->stack = []; + $this->activeFormatting = []; + $this->fosterCharacterTails = []; + $this->templateInsertionModes = []; + } + + protected function consumeMarkup($start) + { + $next = $this->source[$start + 1] ?? null; + + if ($this->startsBogusClosingTag($start, $next)) { + $end = $this->bogusCommentEnd($start); + $this->appendOpaqueMarkup( + $start, + $end, + substr($this->source, $start, $end - $start), + 'comment' + ); + + return $end; + } + + if ($this->startsLiteralLessThan($start, $next)) { + $this->appendParsedText('<'); + + return $start + 1; + } + + $end = $this->markupEnd($start); + $markup = substr($this->source, $start, $end - $start); + + if ($next === '!' || $next === '?') { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + $closing = $next === '/'; + $name = $this->tagName($start, $end, $closing); + + if ($closing) { + return $this->consumeEndTag($start, $end, $markup, $name); + } + + if ($name === false) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + return $this->consumeStartTag($start, $end, $markup, $name); + } + + protected function consumeStartTag($start, $end, $markup, $name) + { + $sourcePositionContainer = $this->currentContainer(); + $sourcePositionAnchor = $this->arena->sourceAnchor[$sourcePositionContainer] ?? null; + + $startsInHtmlSelectMode = $this->openSelect !== null && $this->isInHtmlSelectMode(); + + if ($startsInHtmlSelectMode && $this->consumeStartTagInSelectMode($name, $start, $end, $markup)) { + return $end; + } + + if ($startsInHtmlSelectMode && $name === 'hr') { + $this->closeImpliedElements('optgroup'); + } + + $sourceContainer = $this->currentContainer(); + [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); + + if ($this->shouldExitForeignContentBeforeStart($sourceContainer, $name, $markup)) { + $this->exitForeignContent(); + $sourceContainer = $this->currentContainer(); + } + + $processStartAsHtml = $this->startTagUsesHtmlRules($name, $sourceContainer); + $namespace = $this->namespaceForStartTag($name, $sourceContainer); + $isHtmlElement = $namespace === 'html'; + + if ($isHtmlElement && $name === 'image') { + $name = 'img'; + } + + if ($this->shouldIgnoreFrameStart($name, $isHtmlElement, $processStartAsHtml)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + if ($isHtmlElement && $processStartAsHtml && $name !== null) { + $this->prepareTemplateInsertionModeForStart($name); + } + + if ($this->shouldIgnoreFormStart($name, $isHtmlElement)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + if ($this->shouldIgnoreTableStructureStart($name, $isHtmlElement)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + $selectInTable = $this->isTableSelectStart($name, $isHtmlElement); + $foster = $this->initialFosterState($name, $namespace, $sourceContainer, $processStartAsHtml); + $tableForm = $this->isTableFormStart($name, $isHtmlElement); + + if ($name !== null && $isHtmlElement) { + $foster = $this->prepareFormattingForStart( + $name, + $foster, + $tableForm, + $startsInHtmlSelectMode + ); + } + + if ($this->shouldReconstructFormattingBeforeStart($name, $processStartAsHtml)) { + $this->reconstructActiveFormatting(); + + if ($foster && $this->tableMode() === TableMode::Normal) { + $foster = false; + } + } + + $selfClosing = $this->isSelfClosingTag($start, $end); + $elementName = $this->elementNameForNamespace($name, $namespace); + $element = $this->arena->element($elementName, $markup, $selfClosing, $namespace); + $this->elementSourceStarts[$element] = $start; + if ($isHtmlElement && $name === 'form' && ! $this->hasOpenHtmlTemplate()) { + $this->formElement = $element; + } + + if ($tableForm || $this->isFosteredHiddenInput($element, $name, $foster)) { + $foster = false; + } + + $this->insertStartElement( + $element, + $sourceContainer, + $sourcePositionAnchor, + $sourceTable, + $sourceTableTail, + $foster + ); + + if ($this->regionStarts) { + $this->attachEmbeddedRegions($element, $start, $end); + } + + $this->pushStartElement( + $element, + $name, + $namespace, + $selfClosing, + $selectInTable, + $tableForm + ); + + return $end; + } + + protected function consumeEndTag($start, $end, $markup, $name) + { + if (! is_string($name)) { + if ($name === null && $this->closeDynamicElement($start, $end, $markup)) { + return $end; + } + + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + [$processAsHtml, $foreignMatch] = $this->endTagContext($name); + $closesCurrentTextElement = $this->closesCurrentTextElement($name); + + if ($this->shouldCloseSelectBeforeEndTag($name)) { + if (! $this->hasOpenElementInTableScope($name)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + $this->closeOpenElement('select'); + } + + if ($this->shouldIgnoreEndTagInSelect($name, $closesCurrentTextElement)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + if ($this->shouldCloseColgroupBeforeEndTag($name, $processAsHtml)) { + array_pop($this->stack); + } + + if ($processAsHtml) { + $this->prepareTableEnd($name); + } + + if ($processAsHtml && in_array($name, ['frame', 'frameset'], true)) { + $this->appendOpaqueMarkup($start, $end, $markup); + + return $end; + } + + if ($processAsHtml && $name === 'br') { + $this->appendRecoveredBreak($start, $end, $markup); + + return $end; + } + + if ($this->isHtmlFormEndTag($name, $processAsHtml)) { + $this->consumeFormEndTag($start, $end, $markup); + + return $end; + } + + if ($processAsHtml + && isset(self::$formattingElements[$name]) + && $this->closeFormattingElement($name, $markup, $start, $end)) { + return $end; + } + + if ($processAsHtml && $name === 'p') { + $match = $this->htmlEndTagMatchIndex($name); + + if ($match === null) { + $paragraph = $this->arena->element('p', '', false, 'html', true); + $container = $this->currentContainer(); + + if ($this->shouldFosterContent()) { + $this->fosterNode($paragraph, $container); + } else { + $this->arena->append($container, $paragraph); + } + + $this->stack[] = $paragraph; + $match = count($this->stack) - 1; + } + } elseif ($processAsHtml) { + $match = $this->htmlEndTagMatchIndex($name); + } else { + $match = $foreignMatch; + } + + if ($match === null) { + $this->appendOpaqueMarkup($start, $end, $markup); + } else { + $this->closeMatchedElement($match, $name, $markup, $start, $end); + } + + return $end; + } + + protected function appendRecoveredBreak($start, $end, $markup) + { + $sourceContainer = $this->currentContainer(); + [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); + $foster = $this->prepareTableStart('br'); + + if ($this->activeFormatting + && $this->characterTokenUsesHtmlRules($this->currentContainer())) { + $this->reconstructActiveFormatting(); + } + + $element = $this->arena->element('br', $markup, false, 'html'); + $this->elementSourceStarts[$element] = $start; + + $this->insertStartElement( + $element, + $sourceContainer, + $this->arena->sourceAnchor[$sourceContainer] ?? null, + $sourceTable, + $sourceTableTail, + $foster + ); + + if ($this->regionStarts) { + $this->attachEmbeddedRegions($element, $start, $end); + } + } + + protected function appendOpaqueMarkup($start, $end, $markup, $forcedType = null) + { + [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); + $type = $forcedType; + + if ($type === null) { + $type = $this->opaqueMarkupType($markup); + } + + $opaque = $this->arena->opaque($markup, $type); + $container = $this->currentContainer(); + $this->arena->append($container, $opaque); + $this->anchorRecoveredSourceNode($opaque, $container, $sourceTable, $sourceTableTail); + + foreach ($this->regionsBetween($start, $end) as $region) { + $graphNode = $this->makeAntlers($region['node'], $region['start'], $region['end']); + $this->arena->embedded[$opaque][] = $graphNode; + $this->arena->setParent($graphNode, $opaque); + $this->arena->embeddedOwner[$graphNode] = $opaque; + + [$chain] = $this->insertionChain($this->arena->parent($opaque)); + + $this->recordPlacement($region['node'], [ + 'bucket' => 'opaque', + 'type' => $type, + 'chain' => $chain, + 'markupStart' => $start, + 'markupEnd' => $end, + 'regionStart' => $region['start'], + ]); + } + } + + protected function appendTextWithAntlers($start, $end, $container) + { + if ($this->shouldReconstructFormattingInPlaintext()) { + $this->reconstructActiveFormatting(); + $container = $this->currentContainer(); + } + + $position = $start; + + while ($position < $end) { + if (isset($this->regionEnds[$position])) { + $position = $this->appendAntlers($position, $container); + + continue; + } + + $next = $this->nextRegionStart($position); + + if ($next === null || $next > $end) { + $next = $end; + } + + $this->arena->append($container, $this->arena->text(substr($this->source, $position, $next - $position))); + $position = $next; + } + } + + protected function shouldCloseImpliedElementsBeforeStart($name, $tableForm, $startsInHtmlSelectMode) + { + if ($tableForm || empty($this->stack)) { + return false; + } + + if ($startsInHtmlSelectMode && $name === 'hr') { + return false; + } + + if (isset(self::$pClosingElements[$name])) { + return true; + } + + if (isset(self::$impliedCloseTargets[$name])) { + return true; + } + + if (isset(self::$headingElements[$name])) { + return true; + } + + return $name === 'button'; + } + + protected function shouldIgnoreFormStart($name, $isHtmlElement) + { + if (! $isHtmlElement || $name !== 'form' || $this->formElement === null) { + return false; + } + + return ! $this->hasOpenHtmlTemplate(); + } + + protected function shouldIgnoreTableStructureStart($name, $isHtmlElement) + { + if (! $isHtmlElement || ! isset(self::$tableStructureStarts[$name])) { + return false; + } + + return $this->tableStructureMode() === null && ! $this->hasOpenHtmlTemplate(); + } + + protected function isTableSelectStart($name, $isHtmlElement) + { + if (! $isHtmlElement || $name !== 'select') { + return false; + } + + return in_array( + $this->tableStructureMode(), + [ + TableMode::Table, + TableMode::TableBody, + TableMode::Row, + TableMode::Cell, + TableMode::Caption, + TableMode::ColumnGroup, + ], + true + ); + } + + protected function isTableFormStart($name, $isHtmlElement) + { + if (! $isHtmlElement || $name !== 'form') { + return false; + } + + return in_array($this->tableStructureMode(), [TableMode::Table, TableMode::TableBody, TableMode::Row], true); + } + + protected function prepareFormattingForStart($name, $foster, $tableForm, $startsInHtmlSelectMode) + { + if ($name === 'nobr' && ! empty($this->activeFormatting)) { + $this->reconstructActiveFormatting(); + + if ($this->activeFormattingIndex($name) === null) { + $stackIndex = $this->htmlElementInScopeIndex( + ['nobr' => true], + self::$scopeBoundaries + ); + + if ($stackIndex !== null) { + $this->sliceOpenStack($stackIndex); + $this->reconstructActiveFormatting(); + } + } + } + + $formattingIndex = $this->activeFormattingIndex($name); + + if (in_array($name, ['a', 'nobr'], true) && $formattingIndex !== null) { + $formatting = $this->activeFormatting[$formattingIndex]; + $stackDepth = count($this->stack); + $this->closeFormattingElement($name); + $activeIndex = array_search($formatting, $this->activeFormatting, true); + + if ($activeIndex !== false) { + array_splice($this->activeFormatting, $activeIndex, 1); + } + + $stackIndex = array_search($formatting, $this->stack, true); + + if ($stackIndex !== false) { + array_splice($this->stack, $stackIndex, 1); + } + + if ($this->shouldRefosterAfterStackChange($foster, $stackDepth)) { + $foster = $this->prepareTableStart($name); + } + } + + $stackDepth = count($this->stack); + + if ($this->shouldCloseImpliedElementsBeforeStart($name, $tableForm, $startsInHtmlSelectMode)) { + $this->closeImpliedElements($name); + } + + if ($this->shouldRefosterAfterStackChange($foster, $stackDepth)) { + $foster = $this->prepareTableStart($name); + } + + return $foster; + } + + protected function shouldRefosterAfterStackChange($foster, $previousStackDepth) + { + if ($foster || count($this->stack) === $previousStackDepth) { + return false; + } + + return $this->shouldFosterContent(); + } + + protected function shouldReconstructFormattingBeforeStart($name, $processStartAsHtml) + { + if ($name === null || ! $processStartAsHtml || empty($this->activeFormatting)) { + return false; + } + + if (in_array($name, ['applet', 'button', 'marquee', 'object', 'select', 'xmp'], true)) { + return true; + } + + if (isset(self::$formattingBreakStarts[$name]) || isset(self::$rawTextElements[$name])) { + return false; + } + + return ! in_array($name, ['rb', 'rp', 'rt', 'rtc'], true); + } + + protected function elementNameForNamespace($name, $namespace) + { + if ($namespace !== 'svg' || ! isset(self::$svgTagNames[$name])) { + return $name; + } + + return self::$svgTagNames[$name]; + } + + protected function shouldPushElement($name, $namespace, $selfClosing) + { + if ($namespace !== 'html' + || \Statamic\View\Instrumentation\ComponentPrefixes::matches($name, $this->componentPrefixes)) { + return ! $selfClosing; + } + + return $name === null || ! isset(self::$voidElements[$name]); + } + + protected function startsTextElement($name) + { + if ($name === null) { + return false; + } + + return isset(self::$rawTextElements[$name]) || $name === 'plaintext'; + } + + protected function opaqueMarkupType($markup) + { + if (strncmp($markup, '', $start + 4); + $bangEnd = strpos($this->source, '--!>', $start + 4); + + if ($normalEnd === false) { + $end = $bangEnd; + $terminatorLength = 4; + } elseif ($bangEnd === false || $normalEnd < $bangEnd) { + $end = $normalEnd; + $terminatorLength = 3; + } else { + $end = $bangEnd; + $terminatorLength = 4; + } + + return $this->resolvedEndOffset($end, $length, $terminatorLength); + } + + if ($cdata) { + $end = strpos($this->source, ']]>', $start + 9); + + return $this->resolvedEndOffset($end, $length, 3); + } + + } + + $scanStart = $start + 1; + + if ($comment) { + $scanStart = $start + 4; + } + + for ($index = $scanStart; $index < $length; $index++) { + if (isset($this->regionEnds[$index])) { + $index = $this->regionEnds[$index] - 1; + + continue; + } + + if ($comment) { + if (substr($this->source, $index, 3) === '-->') { + return $index + 3; + } + + if (substr($this->source, $index, 4) === '--!>') { + return $index + 4; + } + + if ($index === $start + 4 && $this->source[$index] === '>') { + return $index + 1; + } + + if ($index === $start + 4 && substr($this->source, $index, 2) === '->') { + return $index + 2; + } + + continue; + } + + if ($cdata) { + if (substr($this->source, $index, 3) === ']]>') { + return $index + 3; + } + + continue; + } + + if ($this->source[$index] === '>') { + return $index + 1; + } + } + + return $length; + } + + protected function bogusCommentEnd($start) + { + $length = strlen($this->source); + + for ($index = $start + 2; $index < $length; $index++) { + if (isset($this->regionEnds[$index])) { + $index = $this->regionEnds[$index] - 1; + + continue; + } + + if ($this->source[$index] === '>') { + return $index + 1; + } + } + + return $length; + } + + protected function tagMarkupEnd($start, $length) + { + $state = 'tag-name'; + $quote = null; + $index = $start + 1; + + if (($this->source[$index] ?? null) === '/') { + $index++; + } + + for (; $index < $length; $index++) { + if (isset($this->regionEnds[$index])) { + if ($state === 'before-value') { + $state = 'unquoted-value'; + } elseif ($state === 'before-attribute' || $state === 'after-attribute') { + $state = 'attribute-name'; + } + + $index = $this->regionEnds[$index] - 1; + + continue; + } + + $char = $this->source[$index]; + + if ($state === 'quoted-value') { + if ($char === $quote) { + $state = 'before-attribute'; + $quote = null; + } + + continue; + } + + if ($state === 'unquoted-value') { + if ($char === '>') { + return $index + 1; + } + + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } + + continue; + } + + if ($state === 'before-value') { + if ($this->isHtmlSpace($char)) { + continue; + } + + if ($char === '"' || $char === "'") { + $state = 'quoted-value'; + $quote = $char; + } elseif ($char === '>') { + return $index + 1; + } else { + $state = 'unquoted-value'; + } + + continue; + } + + if ($state === 'tag-name') { + if ($char === '>') { + return $index + 1; + } + + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } elseif ($char === '/') { + $state = 'self-closing'; + } + + continue; + } + + if ($state === 'attribute-name') { + if ($char === '=') { + $state = 'before-value'; + } elseif ($char === '>') { + return $index + 1; + } elseif ($char === '/') { + $state = 'self-closing'; + } elseif ($this->isHtmlSpace($char)) { + $state = 'after-attribute'; + } + + continue; + } + + if ($state === 'after-attribute') { + if ($char === '=') { + $state = 'before-value'; + } elseif ($char === '>') { + return $index + 1; + } elseif ($char === '/') { + $state = 'self-closing'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + } + + continue; + } + + if ($state === 'self-closing') { + if ($char === '>') { + return $index + 1; + } + + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } else { + $state = 'attribute-name'; + } + + continue; + } + + if ($char === '>') { + return $index + 1; + } + + if (! $this->isHtmlSpace($char) && $char !== '/') { + $state = 'attribute-name'; + } + } + + return $length; + } + + protected function nextRegionStart($position) + { + $count = count($this->regionStarts); + + while ($this->regionCursor < $count + && $this->regionStarts[$this->regionCursor] < $position) { + $this->regionCursor++; + } + + return $this->regionStarts[$this->regionCursor] ?? null; + } + + /** + * @return string|null|false Static name, null for a dynamic name, or false for a missing name. + */ + protected function tagName($start, $end, $closing) + { + $index = $start + 1; + + if ($closing) { + $index++; + + while ($index < $end && $this->isHtmlSpace($this->source[$index])) { + $index++; + } + } + + $name = ''; + $dynamic = false; + + for (; $index < $end; $index++) { + if (isset($this->regionEnds[$index])) { + if (! ($this->regionNodes[$index]->isComment ?? false)) { + $dynamic = true; + } + + $index = $this->regionEnds[$index] - 1; + + continue; + } + + $char = $this->source[$index]; + + if ($char === '>' || $char === '/' || $this->isHtmlSpace($char)) { + break; + } + + if ($char === "\0") { + $char = "\xEF\xBF\xBD"; + } + + $name .= $char; + } + + if ($name === '' && ! $dynamic) { + return false; + } + + if ($dynamic) { + return null; + } + + return strtolower($name); + } + + protected function isSelfClosingTag($start, $end) + { + $slash = $end - 2; + + if ($slash <= $start || $this->source[$slash] !== '/') { + return false; + } + + $index = $start + 1; + + while ($index < $slash) { + if (isset($this->regionEnds[$index])) { + $index = $this->regionEnds[$index]; + + continue; + } + + $char = $this->source[$index]; + + if ($this->isHtmlSpace($char) || $char === '/' || $char === '>') { + break; + } + + $index++; + } + + $state = 'before-attribute'; + $quote = null; + + for (; $index < $slash; $index++) { + if (isset($this->regionEnds[$index])) { + if ($state === 'before-value') { + $state = 'unquoted-value'; + } + + $index = $this->regionEnds[$index] - 1; + + continue; + } + + $char = $this->source[$index]; + + if ($state === 'quoted-value') { + if ($char === $quote) { + $state = 'before-attribute'; + $quote = null; + } + + continue; + } + + if ($state === 'unquoted-value') { + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } + + continue; + } + + if ($state === 'before-value') { + if ($this->isHtmlSpace($char)) { + continue; + } + + if ($char === '"' || $char === "'") { + $state = 'quoted-value'; + $quote = $char; + } else { + $state = 'unquoted-value'; + } + + continue; + } + + if ($state === 'attribute-name') { + if ($char === '=') { + $state = 'before-value'; + } elseif ($this->isHtmlSpace($char)) { + $state = 'after-attribute-name'; + } + + continue; + } + + if ($state === 'after-attribute-name') { + if ($char === '=') { + $state = 'before-value'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + } + + continue; + } + + if (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + } + } + + return ! in_array($state, ['before-value', 'quoted-value', 'unquoted-value'], true); + } + + protected function isHtmlSpace($char) + { + return HtmlSpec::isHtmlSpace($char); + } + + protected function resolvedEndOffset($offset, $sourceLength, $terminatorLength = 0) + { + if ($offset === null || $offset === false) { + return $sourceLength; + } + + return $offset + $terminatorLength; + } + + protected function regionIndexAtOrAfter($position) + { + $low = 0; + $high = count($this->regionStarts); + + while ($low < $high) { + $middle = ($low + $high) >> 1; + + if ($this->regionStarts[$middle] < $position) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + protected function indexAntlersByteRegions(array $nodes) + { + $characterRegions = []; + $needed = [0 => true]; + + foreach ($nodes as $node) { + if (! $node instanceof LanguageAntlersNode || $node->startPosition === null || $node->endPosition === null) { + continue; + } + + $start = $node->startPosition->offset; + $end = $node->endPosition->offset + 1; + $characterRegions[] = [$start, $end, $node]; + $needed[$start] = true; + $needed[$end] = true; + } + + $this->regionEnds = []; + $this->regionNodes = []; + $this->regionStarts = []; + + if (empty($characterRegions)) { + return; + } + + $byteOffsets = $this->characterToByteOffsets($needed); + + foreach ($characterRegions as [$start, $end, $node]) { + if (isset($byteOffsets[$start], $byteOffsets[$end])) { + $this->regionEnds[$byteOffsets[$start]] = $byteOffsets[$end]; + $this->regionNodes[$byteOffsets[$start]] = $node; + } + } + + ksort($this->regionEnds, SORT_NUMERIC); + $this->regionStarts = array_keys($this->regionEnds); + } + + protected function characterToByteOffsets(array $needed) + { + return CharacterOffsets::toBytes($this->source, array_keys($needed)); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Container.php b/src/View/Antlers/Language/Analyzers/Html/Container.php new file mode 100644 index 00000000000..ae7a980a05d --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Container.php @@ -0,0 +1,372 @@ +normalizeArenaNode($content); + $this->assertArenaInsertable($replacement); + $existing = $this->children(); + + $this->append($replacement); + + foreach ($existing as $node) { + if ($node !== $replacement) { + $this->removeChild($node); + } + } + + return $this; + } + + return NodeSequence::childrenOf($this->document, $this->arenaContainerId()); + } + + public function children() + { + return array_map([$this->document, 'handle'], $this->document->arena()->children($this->arenaContainerId())); + } + + /** @return ElementCollection */ + public function descendants($name = null) + { + return $this->arenaDescendants($this->descendantNameFilter($name)); + } + + public function firstDescendant($name = null) + { + return $this->arenaFirstDescendant($this->descendantNameFilter($name)); + } + + public function firstChild() + { + $child = $this->document->arena()->firstChild($this->arenaContainerId()); + + return $this->containerChild($child); + } + + public function lastChild() + { + $child = $this->document->arena()->lastChild($this->arenaContainerId()); + + return $this->containerChild($child); + } + + public function append($content) + { + $node = $this->normalizeArenaNode($content); + $this->assertArenaInsertable($node); + $this->detachArenaNodeForInsert($node); + $this->document->arena()->append($this->arenaContainerId(), $node->arenaId()); + $this->markDirty(); + + return $this; + } + + public function prepend($content) + { + $node = $this->normalizeArenaNode($content); + $this->assertArenaInsertable($node); + $this->detachArenaNodeForInsert($node); + $arena = $this->document->arena(); + $first = $arena->firstChild($this->arenaContainerId()); + + if ($first === null) { + $arena->append($this->arenaContainerId(), $node->arenaId()); + } else { + $arena->insertBefore($first, $node->arenaId()); + } + + $this->markDirty(); + + return $this; + } + + public function hasChild(Node $node) + { + if ($node->document() !== $this->document) { + return false; + } + + if ($node->arenaId() === null) { + return false; + } + + return $this->document->arena()->hasChild($this->arenaContainerId(), $node->arenaId()); + } + + public function siblingsFor(Node $node) + { + return $this->children(); + } + + public function insertBefore(Node $reference, $content) + { + $this->insertArenaRelative($reference, $content, false); + } + + public function insertAfter(Node $reference, $content) + { + $this->insertArenaRelative($reference, $content, true); + } + + public function replaceChild(Node $reference, $content) + { + $this->replaceArenaChild($reference, $content); + } + + public function removeChild(Node $node) + { + if (! $this->hasChild($node)) { + return; + } + + $this->releaseArenaSourceAnchors($node->arenaId()); + $this->document->arena()->detach($node->arenaId()); + $this->markDirty(); + } + + public function createElement($name) + { + return $this->document->createArenaElement($name); + } + + protected function renderChildren() + { + return $this->document->renderArenaChildren($this->arenaContainerId()); + } + + protected function sourceChildren() + { + return $this->children(); + } + + protected function normalizeArenaNode($content) + { + if ($content instanceof Node) { + if ($content->document() !== $this->document) { + throw new \InvalidArgumentException('HTML graph nodes cannot be moved between documents.'); + } + + if ($content->arenaId() === null) { + throw new \InvalidArgumentException('Only nodes created by this HTML document can be inserted.'); + } + + return $content; + } + + $id = $this->document->arena()->text((string) $content); + + return $this->document->handle($id); + } + + protected function assertArenaInsertable(Node $node) + { + $arena = $this->document->arena(); + + if (isset($arena->embeddedOwner[$node->arenaId()])) { + throw new \LogicException('Antlers regions embedded in HTML markup cannot be moved as DOM children. Mutate the owning node instead.'); + } + + for ($container = $this->arenaContainerId(); $container !== null; $container = $arena->parent($container)) { + if ($container === $node->arenaId()) { + throw new \LogicException('HTML graph nodes cannot be inserted into themselves or their descendants.'); + } + } + } + + protected function detachArenaNodeForInsert(Node $node) + { + $id = $node->arenaId(); + $arena = $this->document->arena(); + + if ($arena->parent($id) !== null && ! isset($arena->embeddedOwner[$id])) { + $this->releaseArenaSourceAnchors($id); + $arena->detach($id); + } + } + + protected function insertArenaRelative(Node $reference, $content, $after) + { + if (! $this->hasChild($reference)) { + return; + } + + $node = $this->normalizeArenaNode($content); + + if ($node === $reference) { + return; + } + + $this->assertArenaInsertable($node); + $this->detachArenaNodeForInsert($node); + $arena = $this->document->arena(); + $referenceId = $reference->arenaId(); + $nodeId = $node->arenaId(); + $anchor = $arena->sourceAnchor[$referenceId] ?? null; + + if ($after) { + $arena->insertAfter($referenceId, $nodeId); + } else { + $arena->insertBefore($referenceId, $nodeId); + } + + if ($anchor !== null) { + $sourceAnchor = $arena->anchor($nodeId); + + if ($after) { + $arena->insertAfter($anchor, $sourceAnchor); + } else { + $arena->insertBefore($anchor, $sourceAnchor); + } + } + + $this->markDirty(); + } + + protected function replaceArenaChild(Node $reference, $content) + { + if (! $this->hasChild($reference)) { + return; + } + + $node = $this->normalizeArenaNode($content); + + if ($node === $reference) { + return; + } + + $this->assertArenaInsertable($node); + $this->detachArenaNodeForInsert($node); + $arena = $this->document->arena(); + $referenceId = $reference->arenaId(); + $nodeId = $node->arenaId(); + $anchor = $arena->sourceAnchor[$referenceId] ?? null; + $arena->insertBefore($referenceId, $nodeId); + $arena->detach($referenceId); + + if ($anchor !== null) { + unset($arena->sourceAnchor[$referenceId]); + $arena->anchorTarget[$anchor] = $nodeId; + $arena->sourceAnchor[$nodeId] = $anchor; + } + + $this->releaseArenaSourceAnchors($referenceId); + $this->markDirty(); + } + + protected function releaseArenaSourceAnchors($id) + { + $arena = $this->document->arena(); + $anchor = $arena->sourceAnchor[$id] ?? null; + + if ($anchor !== null) { + unset($arena->sourceAnchor[$id]); + $arena->anchorTarget[$anchor] = null; + } + + for ($child = $arena->firstChild($id); $child !== null; $child = $arena->next($child)) { + if (($arena->kind($child) & Arena::KIND_MASK) === Arena::SOURCE_ANCHOR) { + $target = $arena->anchorTarget[$child] ?? null; + + if ($target !== null) { + unset($arena->sourceAnchor[$target]); + $arena->anchorTarget[$child] = null; + } + } else { + $this->releaseArenaSourceAnchors($child); + } + } + } + + protected function arenaContainerId() + { + if ($this instanceof Document) { + return 0; + } + + return $this->arenaId; + } + + protected function descendantNameFilter($name) + { + if ($name === null) { + return []; + } + + return [(string) $name]; + } + + protected function containerChild($child) + { + if ($child === null) { + return null; + } + + return $this->document->handle($child); + } + + protected function arenaDescendants(array $names) + { + $arena = $this->document->arena(); + $set = array_map('strtolower', $names); + $elements = []; + + $stack = array_reverse($arena->children($this->arenaContainerId())); + + while ($stack !== []) { + $id = array_pop($stack); + + if (($arena->kind($id) & Arena::KIND_MASK) !== Arena::ELEMENT) { + continue; + } + + if ($set === [] || $this->arenaNameMatches($arena->name[$id], $set)) { + $elements[] = $this->document->handle($id); + } + + for ($child = $arena->lastChild($id); $child !== null; $child = $arena->previous($child)) { + $stack[] = $child; + } + } + + return new ElementCollection($elements); + } + + protected function arenaFirstDescendant(array $names) + { + $arena = $this->document->arena(); + $set = array_map('strtolower', $names); + $stack = array_reverse($arena->children($this->arenaContainerId())); + + while ($stack !== []) { + $id = array_pop($stack); + + if (($arena->kind($id) & Arena::KIND_MASK) !== Arena::ELEMENT) { + continue; + } + + if ($set === [] || $this->arenaNameMatches($arena->name[$id], $set)) { + return $this->document->handle($id); + } + + for ($child = $arena->lastChild($id); $child !== null; $child = $arena->previous($child)) { + $stack[] = $child; + } + } + + return null; + } + + protected function arenaNameMatches($name, array $set) + { + return $name !== null && in_array(strtolower((string) $name), $set, true); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Document.php b/src/View/Antlers/Language/Analyzers/Html/Document.php new file mode 100644 index 00000000000..39fee6b8fca --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Document.php @@ -0,0 +1,528 @@ + */ + protected $antlersNodes = []; + + /** @var array */ + protected $handles = []; + + /** @var array */ + protected $regionEnds = []; + + /** @var array */ + protected $regionNodes = []; + + /** @var int[] */ + protected $regionStarts = []; + + protected $regionCursor = 0; + + /** @var int[] */ + protected $stack = []; + + /** @var array */ + protected $activeFormatting = []; + + protected $scriptEscape = self::SCRIPT_DATA; + + /** @var int|null */ + protected $openSelect; + + protected $selectInTable = false; + + /** @var int|null */ + protected $formElement; + + /** @var int|null */ + protected $textElement; + + protected $hasSeenTable = false; + + /** @var array */ + protected $fosterCharacterTails = []; + + /** @var int */ + protected $tableStartDepth = 0; + + /** @var array template element id => current template insertion mode */ + protected $templateInsertionModes = []; + + /** @var array> keyed by spl_object_id of the language node */ + protected $regionPlacements = []; + + /** @var array element id => byte offset of its opening `<` */ + protected $elementSourceStarts = []; + + /** @var string */ + protected $authoredSource; + + /** @var array|null keyed by normalized byte offset */ + protected $authoredPositions = null; + + protected array $componentPrefixes = []; + + private function __construct($source, $authoredSource = null) + { + $this->document = $this; + $this->arena = new Arena(); + $this->source = $source; + $this->authoredSource = $authoredSource ?? $source; + } + + public function arena() + { + return $this->arena; + } + + /** @return array> */ + public function regionPlacements() + { + return $this->regionPlacements; + } + + /** @return int|null */ + public function elementSourceStart($id) + { + if ($id === null) { + return null; + } + + return $this->elementSourceStarts[$id] ?? null; + } + + /** @return int|null */ + public function elementSourceCharacterOffset($id) + { + return $this->authoredElementPosition($id)['offset'] ?? null; + } + + /** @return int|null */ + public function elementSourceLine($id) + { + return $this->authoredElementPosition($id)['line'] ?? null; + } + + /** @return array{offset: int, line: int}|null */ + protected function authoredElementPosition($id) + { + $byte = $this->elementSourceStart($id); + + if ($byte === null) { + return null; + } + + if ($this->authoredPositions === null) { + $this->resolveAuthoredPositions(); + } + + return $this->authoredPositions[$byte] ?? null; + } + + protected function resolveAuthoredPositions() + { + $this->authoredPositions = []; + $bytes = array_values(array_unique($this->elementSourceStarts)); + + if ($bytes === []) { + return; + } + + $normalizedCharacters = CharacterOffsets::toCharacters($this->source, $bytes); + [$authoredBytes, $authoredCharacters] = CharacterOffsets::normalizedToBytesAndCharacters( + $this->authoredSource, + array_values($normalizedCharacters) + ); + + usort($bytes, fn ($left, $right) => $authoredBytes[$normalizedCharacters[$left]] <=> $authoredBytes[$normalizedCharacters[$right]]); + + $line = 1; + $previousByte = 0; + + foreach ($bytes as $byte) { + $authoredByte = $authoredBytes[$normalizedCharacters[$byte]]; + + if ($authoredByte > $previousByte) { + preg_match_all( + '/\r\n|\r|\n/', + substr($this->authoredSource, $previousByte, $authoredByte - $previousByte), + $lineEndings + ); + $line += count($lineEndings[0]); + $previousByte = $authoredByte; + } + + $this->authoredPositions[$byte] = [ + 'offset' => $authoredCharacters[$authoredByte], + 'line' => $line, + ]; + } + } + + protected function recordPlacement(LanguageAntlersNode $node, array $facts) + { + $identity = spl_object_id($node); + + if (! isset($this->regionPlacements[$identity])) { + $this->regionPlacements[$identity] = $facts; + } + } + + /** @return array{0: array, 1: int|null} */ + protected function insertionChain($id) + { + $chain = []; + $innermost = null; + + for (; $id !== null && $id !== 0; $id = $this->arena->parent($id)) { + if (($this->arena->kind($id) & Arena::KIND_MASK) !== Arena::ELEMENT) { + continue; + } + + if ($innermost === null && isset($this->elementSourceStarts[$id])) { + $innermost = $id; + } + + $chain[] = $this->arena->name[$id]; + } + + return [array_reverse($chain), $innermost]; + } + + public function handle($id) + { + if ($id === 0) { + return $this; + } + + if (isset($this->handles[$id])) { + return $this->handles[$id]; + } + + switch ($this->arena->kind($id) & Arena::KIND_MASK) { + case Arena::ELEMENT: + $handle = Element::fromArena($this, $id); + break; + case Arena::TEXT: + $handle = Text::fromArena($this, $id); + break; + case Arena::ANTLERS: + $handle = AntlersNode::fromArena($this, $id); + break; + case Arena::OPAQUE: + $handle = OpaqueNode::fromArena($this, $id); + break; + case Arena::SOURCE_ANCHOR: + $handle = SourceAnchor::fromArena($this, $id); + break; + default: + throw new \LogicException('Unknown HTML arena node kind.'); + } + + return $this->handles[$id] = $handle; + } + + public static function fromParser(DocumentParser $parser, array $componentPrefixes = []) + { + $document = new self($parser->getParsedContent(), $parser->getOriginalContent()); + $document->componentPrefixes = $componentPrefixes; + $document->build($parser->getNodes()); + + return $document; + } + + public static function parse($template) + { + if (NodeTypeAnalyzer::$environmentDetails === null) { + NodeTypeAnalyzer::$environmentDetails = app(EnvironmentDetails::class); + } + + $parser = new DocumentParser(); + $parser->parse($template); + + return $parser->html(); + } + + /** + * @internal + * + * @param array $ranges Non-overlapping byte ranges. + * @return array Whether a region is placed in reconstructed formatting. + */ + public static function formattingRecoveryForRegions(string $source, array $ranges, array $componentPrefixes = []): array + { + $document = new self($source); + $document->componentPrefixes = $componentPrefixes; + + foreach ($ranges as [$start, $end]) { + $document->regionEnds[$start] = $end; + $document->regionNodes[$start] = new LanguageAntlersNode; + } + + ksort($document->regionEnds, SORT_NUMERIC); + $document->regionStarts = array_keys($document->regionEnds); + $document->build(null); + $recovery = []; + + foreach ($document->regionNodes as $start => $node) { + $placement = $document->regionPlacements[spl_object_id($node)] ?? []; + $recovered = $placement['reconstructed'] ?? false; + $id = $document->antlersNodes[spl_object_id($node)] ?? null; + + for ($parent = $id; $parent !== null; $parent = $document->arena->parent($parent)) { + $recovered = $recovered || ((bool) ($document->arena->kind($parent) & Arena::SYNTHETIC) + && isset(HtmlSpec::FORMATTING_ELEMENTS[$document->arena->name[$parent] ?? ''])); + } + + $recovery[$start] = $recovered; + } + + return $recovery; + } + + public static function isVoidElement($name) + { + return is_string($name) && isset(self::$voidElements[strtolower($name)]); + } + + public function createArenaElement($name) + { + if (! HtmlSpec::isValidName($name)) { + throw new \InvalidArgumentException('Invalid HTML element name.'); + } + + $id = $this->arena->element($name, '<'.$name.'>'); + + if (! self::isVoidElement($name)) { + $this->arena->closing[$id] = ''; + } + + return $this->handle($id); + } + + public function parent() + { + return null; + } + + public function node(LanguageAntlersNode $node) + { + $id = $this->antlersNodes[spl_object_id($node)] ?? null; + + if ($id === null) { + return null; + } + + return $this->handle($id); + } + + /** @return ElementCollection */ + public function elements(string ...$names): ElementCollection + { + return $this->arenaDescendants($names); + } + + /** @return LazyCollection */ + public function regions($name = null) + { + return new LazyCollection(function () use ($name) { + foreach ($this->regionStarts as $start) { + $node = $this->regionNodes[$start]; + + if (! $this->isRegionOpening($node, $name)) { + continue; + } + + $opening = $this->node($node); + $closing = $this->node($node->isClosedBy); + + if ($opening instanceof AntlersNode && $closing instanceof AntlersNode) { + yield new Region($opening, $closing); + } + } + }); + } + + protected function isRegionOpening($node, $name) + { + if (! $node instanceof LanguageAntlersNode || $node->isClosingTag) { + return false; + } + + if (! $node->isClosedBy instanceof LanguageAntlersNode) { + return false; + } + + if ($name === null) { + return true; + } + + return $node->name !== null && strcasecmp($node->name->compound, $name) === 0; + } + + public function first(?string $name = null): ?Element + { + if ($name === null) { + return $this->arenaFirstDescendant([]); + } + + return $this->arenaFirstDescendant([$name]); + } + + public function isDirty() + { + return $this->dirty; + } + + public function markDirty() + { + $this->dirty = true; + + return $this; + } + + public function toHtml() + { + if ($this->dirty) { + return $this->renderChildren(); + } + + return $this->source; + } + + public function renderArenaChildren($parent) + { + $html = ''; + + for ($child = $this->arena->firstChild($parent); $child !== null; $child = $this->arena->next($child)) { + if (isset($this->arena->sourceAnchor[$child])) { + continue; + } + + $html .= $this->renderArenaNode($child); + } + + return $html; + } + + public function renderArenaNode($id) + { + $kind = $this->arena->kind($id); + + if (($kind & Arena::KIND_MASK) === Arena::SOURCE_ANCHOR) { + $target = $this->arena->anchorTarget[$id] ?? null; + + if ($target === null + || ($this->arena->sourceAnchor[$target] ?? null) !== $id + || $this->arena->parent($target) === null) { + return ''; + } + + return $this->renderArenaNode($target); + } + + if (isset($this->handles[$id])) { + return $this->handles[$id]->toHtml(); + } + + if (($kind & Arena::KIND_MASK) === Arena::ELEMENT) { + $children = $this->renderArenaChildren($id); + $opening = $this->arena->value[$id]; + + if ($kind & Arena::SYNTHETIC) { + $opening = ''; + } + + return $opening.$children.$this->arena->closing[$id]; + } + + return $this->arena->value[$id]; + } + + public function __toString() + { + return $this->toHtml(); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Element.php b/src/View/Antlers/Language/Analyzers/Html/Element.php new file mode 100644 index 00000000000..7254a29a47b --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Element.php @@ -0,0 +1,587 @@ + */ + protected $attributes = []; + + /** @var array> */ + protected $attributeSpans = []; + + /** @var array */ + protected $changedAttributes = []; + + /** @var array */ + protected $changedAttributeNames = []; + + /** @var array */ + protected $removedAttributes = []; + + protected $attributesParsed = false; + + public function __construct(Document $document, $name, $openingTag, $selfClosing = false, $namespace = 'html') + { + parent::__construct($document); + + $this->name = $name; + $this->namespace = $namespace; + $this->openingTag = $openingTag; + $this->selfClosing = $selfClosing; + } + + public static function fromArena(Document $document, $id) + { + $arena = $document->arena(); + $type = $arena->kind($id); + $element = new self( + $document, + $arena->name[$id], + $arena->value[$id], + (bool) ($type & Arena::SELF_CLOSING), + $arena->namespace($id) + ); + $element->arenaId = $id; + $element->closingTag = $arena->closing[$id]; + $element->synthetic = (bool) ($type & Arena::SYNTHETIC); + + return $element; + } + + public function name(): ?string + { + return $this->name; + } + + public function namespace() + { + return $this->namespace; + } + + public function isHtml() + { + return $this->namespace === 'html'; + } + + public function sourceLine(): ?int + { + if ($this->arenaId === null) { + return null; + } + + return $this->document->elementSourceLine($this->arenaId); + } + + public function sourceOffset(): ?int + { + if ($this->arenaId === null) { + return null; + } + + return $this->document->elementSourceCharacterOffset($this->arenaId); + } + + /** @return LazyCollection */ + public function ancestors() + { + return LazyCollection::make(function () { + for ($element = $this->parentElement(); $element !== null; $element = $element->parentElement()) { + yield $element; + } + }); + } + + /** @return LazyCollection */ + public function ancestorsAndSelf() + { + return LazyCollection::make(function () { + for ($element = $this; $element !== null; $element = $element->parentElement()) { + yield $element; + } + }); + } + + public function closingTag($tag = null) + { + if (func_num_args() === 0) { + return $this->closingTag; + } + + $this->closingTag = (string) $tag; + + if ($this->arenaId !== null) { + $this->document->arena()->closing[$this->arenaId] = $this->closingTag; + } + + return $this; + } + + public function isSelfClosing() + { + return $this->selfClosing; + } + + public function isSynthetic(): bool + { + return $this->synthetic; + } + + public function releaseSourceAnchors() + { + parent::releaseSourceAnchors(); + + foreach ($this->sourceChildren() as $child) { + $child->releaseSourceAnchors(); + } + } + + public function attr(string $name): ?string + { + $this->ensureAttributesParsed(); + $key = strtolower($name); + + if (isset($this->removedAttributes[$key])) { + return null; + } + + if (array_key_exists($key, $this->changedAttributes)) { + return $this->changedAttributes[$key]; + } + + return $this->attributes[$key] ?? null; + } + + public function hasAttribute(string $name): bool + { + $this->ensureAttributesParsed(); + $key = strtolower($name); + + if (isset($this->removedAttributes[$key])) { + return false; + } + + if (array_key_exists($key, $this->changedAttributes)) { + return true; + } + + return array_key_exists($key, $this->attributes); + } + + public function attributes(): array + { + $this->ensureAttributesParsed(); + $attributes = $this->attributes; + + foreach ($this->removedAttributes as $name => $_) { + unset($attributes[$name]); + } + + foreach ($this->changedAttributes as $name => $value) { + $attributes[$name] = $value; + } + + return $attributes; + } + + public function setAttribute($name, $value = null) + { + if ($this->synthetic) { + throw new \LogicException('Reconstructed HTML elements have no source opening tag to mutate.'); + } + + if (! HtmlSpec::isValidName($name)) { + throw new \InvalidArgumentException('Invalid HTML attribute name.'); + } + + $this->ensureAttributesParsed(); + $key = strtolower($name); + unset($this->removedAttributes[$key]); + + if ($value !== null) { + $value = (string) $value; + } + + $attributeName = $name; + + if ($this->isHtml()) { + $attributeName = $key; + } + + $this->changedAttributes[$key] = $value; + $this->changedAttributeNames[$key] = $attributeName; + $this->touchDocument(); + + return $this; + } + + public function removeAttribute($name) + { + if ($this->synthetic) { + throw new \LogicException('Reconstructed HTML elements have no source opening tag to mutate.'); + } + + $this->ensureAttributesParsed(); + $key = strtolower($name); + unset($this->changedAttributes[$key]); + unset($this->changedAttributeNames[$key]); + $this->removedAttributes[$key] = true; + $this->touchDocument(); + + return $this; + } + + public function addClass($class) + { + $value = (string) $this->attr('class'); + + // Authored attributes contain HTML entities; setAttribute values are + // already decoded. Keep Antlers expressions untouched in either case. + if (! array_key_exists('class', $this->changedAttributes)) { + $value = $this->decodeAttributeValue($value); + } + + $classes = HtmlSpec::classList($value); + + foreach (preg_split('/\s+/', trim((string) $class)) as $candidate) { + if ($candidate !== '' && ! in_array($candidate, $classes, true)) { + $classes[] = $candidate; + } + } + + return $this->setAttribute('class', implode(' ', $classes)); + } + + public function embeddedNodes() + { + return array_map([$this->document, 'handle'], $this->document->arena()->embedded[$this->arenaId] ?? []); + } + + public function siblingsFor(Node $node) + { + if (in_array($node->arenaId(), $this->document->arena()->embedded[$this->arenaId] ?? [], true)) { + return $this->embeddedNodes(); + } + + return $this->children(); + } + + protected function render() + { + if ($this->synthetic) { + return $this->renderChildren().$this->closingTag; + } + + return $this->renderOpeningTag().$this->renderChildren().$this->closingTag; + } + + protected function renderOpeningTag() + { + if (empty($this->changedAttributes) && empty($this->removedAttributes)) { + return $this->openingTag; + } + + $edits = []; + + foreach ($this->removedAttributes as $name => $_) { + foreach ($this->attributeSpans[$name] ?? [] as $span) { + $edits[] = [$span['start'], $span['length'], '']; + } + } + + $new = ''; + + foreach ($this->changedAttributes as $name => $value) { + $attribute = ' '.($this->changedAttributeNames[$name] ?? $name); + + if ($value !== null) { + $attribute .= '="'.$this->escapeAttributeValue($value).'"'; + } + + if (isset($this->attributeSpans[$name])) { + $spans = $this->attributeSpans[$name]; + $span = array_shift($spans); + $edits[] = [$span['start'], $span['length'], $attribute]; + + foreach ($spans as $duplicate) { + $edits[] = [$duplicate['start'], $duplicate['length'], '']; + } + } else { + $new .= $attribute; + } + } + + usort($edits, function ($left, $right) { + return $right[0] <=> $left[0]; + }); + + $opening = $this->openingTag; + + foreach ($edits as [$start, $length, $replacement]) { + $opening = substr($opening, 0, $start).$replacement.substr($opening, $start + $length); + } + + if ($new !== '') { + $offset = $this->openingNameEnd(); + $opening = substr($opening, 0, $offset).$new.substr($opening, $offset); + } + + return $opening; + } + + protected function parseAttributes() + { + $length = strlen($this->openingTag); + $index = $this->openingNameEnd(); + + while ($index < $length) { + while ($index < $length && $this->isHtmlSpace($this->openingTag[$index])) { + $index++; + } + + if ($index >= $length || $this->openingTag[$index] === '>') { + break; + } + + if ($this->openingTag[$index] === '/') { + $index++; + + continue; + } + + if (substr($this->openingTag, $index, 2) === '{{') { + $index = $this->antlersEnd($index); + + continue; + } + + $attributeStart = $index; + $dynamicName = false; + + if ($this->openingTag[$index] === '=') { + $index++; + } + + while ($index < $length + && ! HtmlSpec::isAttributeNameDelimiter($this->openingTag[$index])) { + if (substr($this->openingTag, $index, 2) === '{{') { + $dynamicName = true; + $index = $this->antlersEnd($index); + + continue; + } + + $index++; + } + + $name = strtolower(str_replace( + "\0", + "\xEF\xBF\xBD", + substr($this->openingTag, $attributeStart, $index - $attributeStart) + )); + + if ($name === '') { + $index++; + + continue; + } + + $attributeEnd = $index; + + while ($index < $length && $this->isHtmlSpace($this->openingTag[$index])) { + $index++; + } + + $value = null; + + if ($index < $length && $this->openingTag[$index] === '=') { + $index++; + + while ($index < $length && $this->isHtmlSpace($this->openingTag[$index])) { + $index++; + } + + if ($index < $length && ($this->openingTag[$index] === '"' || $this->openingTag[$index] === "'")) { + $quote = $this->openingTag[$index++]; + $valueStart = $index; + + while ($index < $length && $this->openingTag[$index] !== $quote) { + if (substr($this->openingTag, $index, 2) === '{{') { + $index = $this->antlersEnd($index); + + continue; + } + + $index++; + } + + $value = str_replace( + "\0", + "\xEF\xBF\xBD", + substr($this->openingTag, $valueStart, $index - $valueStart) + ); + if ($index < $length) { + $index++; + } + } else { + $valueStart = $index; + + while ($index < $length + && ! $this->isHtmlSpace($this->openingTag[$index]) + && $this->openingTag[$index] !== '>') { + if (substr($this->openingTag, $index, 2) === '{{') { + $index = $this->antlersEnd($index); + + continue; + } + + $index++; + } + + $value = str_replace( + "\0", + "\xEF\xBF\xBD", + substr($this->openingTag, $valueStart, $index - $valueStart) + ); + } + + $attributeEnd = $index; + } + + $spanStart = $attributeStart; + + while ($spanStart > 0 && $this->isHtmlSpace($this->openingTag[$spanStart - 1])) { + $spanStart--; + } + + if ($dynamicName) { + continue; + } + + if (! array_key_exists($name, $this->attributes)) { + $this->attributes[$name] = $value; + } + + $this->attributeSpans[$name][] = [ + 'start' => $spanStart, + 'length' => $attributeEnd - $spanStart, + ]; + } + } + + protected function isHtmlSpace($char) + { + return HtmlSpec::isHtmlSpace($char); + } + + protected function ensureAttributesParsed() + { + if (! $this->attributesParsed) { + $this->attributesParsed = true; + $this->parseAttributes(); + } + } + + protected function openingNameEnd() + { + $length = strlen($this->openingTag); + + for ($index = 1; $index < $length; $index++) { + if (substr($this->openingTag, $index, 2) === '{{') { + $index = $this->antlersEnd($index) - 1; + + continue; + } + + $char = $this->openingTag[$index]; + + if (HtmlSpec::isTagNameDelimiter($char)) { + return $index; + } + } + + return $length; + } + + protected function antlersEnd($start) + { + return $this->antlersEndIn($this->openingTag, $start); + } + + protected function escapeAttributeValue($value) + { + return $this->transformAttributeLiterals( + (string) $value, + fn ($literal) => htmlspecialchars($literal, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + ); + } + + protected function decodeAttributeValue(string $value): string + { + return $this->transformAttributeLiterals( + $value, + fn ($literal) => html_entity_decode($literal, ENT_QUOTES | ENT_HTML5, 'UTF-8') + ); + } + + protected function transformAttributeLiterals(string $value, callable $transform): string + { + $result = ''; + $offset = 0; + $length = strlen($value); + + while (($start = strpos($value, '{{', $offset)) !== false) { + $end = RegionExtents::endInBytes($value, $start, $length); + + if ($end === false) { + break; + } + + $result .= $transform(substr($value, $offset, $start - $offset)); + $region = substr($value, $start, $end - $start); + $result .= $start > 0 && $value[$start - 1] === '@' + ? $transform($region) + : $region; + $offset = $end; + } + + return $result.$transform(substr($value, $offset)); + } + + protected function antlersEndIn($source, $start) + { + $length = strlen($source); + $end = RegionExtents::endInBytes($source, $start, $length); + + if ($end === false) { + return $length; + } + + return $end; + } + + protected function markDirty() + { + $this->touchDocument(); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Node.php b/src/View/Antlers/Language/Analyzers/Html/Node.php new file mode 100644 index 00000000000..ec42eb8a99d --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Node.php @@ -0,0 +1,301 @@ +document = $document; + $this->arenaId = $arenaId; + } + + public function arenaId() + { + return $this->arenaId; + } + + public function document() + { + return $this->document; + } + + public function parent() + { + if ($this->arenaId === null) { + return null; + } + + $parent = $this->document->arena()->parent($this->arenaId); + + if ($parent === null) { + return null; + } + + return $this->document->handle($parent); + } + + public function parentElement(): ?Element + { + $parent = $this->parent(); + + while ($parent !== null && ! $parent instanceof Element) { + $parent = $parent->parent(); + } + + return $parent; + } + + public function previousSibling() + { + return $this->siblingAt(-1); + } + + public function nextSibling() + { + return $this->siblingAt(1); + } + + public function isSourceAnchored() + { + return $this->arenaId !== null + && isset($this->document->arena()->sourceAnchor[$this->arenaId]); + } + + public function sourceAnchor() + { + if ($this->arenaId === null) { + return null; + } + + $anchor = $this->document->arena()->sourceAnchor[$this->arenaId] ?? null; + + if ($anchor === null) { + return null; + } + + return $this->document->handle($anchor); + } + + public function anchorSourceAt(SourceAnchor $anchor) + { + if ($this->arenaId === null || $anchor->arenaId() === null) { + throw new \LogicException('Source anchors must belong to an HTML arena.'); + } + + if ($anchor->document() !== $this->document) { + throw new \InvalidArgumentException('Source anchors and targets must belong to the same HTML document.'); + } + + $arena = $this->document->arena(); + $anchorId = $anchor->arenaId(); + $currentAnchor = $arena->sourceAnchor[$this->arenaId] ?? null; + $currentTarget = $arena->anchorTarget[$anchorId] ?? null; + + if ($currentAnchor === $anchorId && $currentTarget === $this->arenaId) { + return $this; + } + + if ($currentAnchor !== null + && ($arena->anchorTarget[$currentAnchor] ?? null) === $this->arenaId) { + $arena->anchorTarget[$currentAnchor] = null; + } + + if ($currentTarget !== null + && ($arena->sourceAnchor[$currentTarget] ?? null) === $anchorId) { + unset($arena->sourceAnchor[$currentTarget]); + } + + $arena->sourceAnchor[$this->arenaId] = $anchorId; + $arena->anchorTarget[$anchorId] = $this->arenaId; + $this->touchDocument(); + $this->touchArenaNode($anchorId); + + return $this; + } + + public function releaseSourceAnchor() + { + if ($this->arenaId === null) { + return $this; + } + + $arena = $this->document->arena(); + $anchor = $arena->sourceAnchor[$this->arenaId] ?? null; + unset($arena->sourceAnchor[$this->arenaId]); + + if ($anchor !== null) { + $arena->anchorTarget[$anchor] = null; + $this->touchDocument(); + $this->touchArenaNode($anchor); + } + + return $this; + } + + public function releaseSourceAnchors() + { + $anchor = $this->sourceAnchor(); + + if ($anchor !== null) { + $this->releaseSourceAnchor(); + } + } + + public function closest(string $name): ?Element + { + $element = $this; + + if (! $element instanceof Element) { + $element = $this->parentElement(); + } + + while ($element !== null) { + if ($element->is($name)) { + return $element; + } + + $element = $element->parentElement(); + } + + return null; + } + + public function before($content) + { + $this->assertMutableSibling(); + $this->parent()->insertBefore($this, $content); + + return $this; + } + + public function after($content) + { + $this->assertMutableSibling(); + $this->parent()->insertAfter($this, $content); + + return $this; + } + + public function replaceWith($content) + { + $this->assertMutableSibling(); + $this->parent()->replaceChild($this, $content); + + return $this; + } + + /** @return $this */ + public function wrapWith(Element $element) + { + if ($element === $this) { + throw new \LogicException('HTML graph nodes cannot wrap themselves.'); + } + + $this->assertMutableSibling(); + $this->replaceWith($element); + $element->append($this); + + return $this; + } + + public function remove() + { + $this->assertMutableSibling(); + $this->parent()->removeChild($this); + + return $this; + } + + public function toHtml(): string + { + return $this->render(); + } + + public function __toString() + { + return $this->toHtml(); + } + + abstract protected function render(); + + protected function siblingAt($direction) + { + if ($this->arenaId === null) { + return null; + } + + $owner = $this->document->arena()->embeddedOwner[$this->arenaId] ?? null; + + if ($owner !== null) { + $siblings = $this->document->arena()->embedded[$owner]; + $index = array_search($this->arenaId, $siblings, true); + + if ($index === false) { + return null; + } + + $sibling = $siblings[$index + $direction] ?? null; + + return $this->siblingHandle($sibling); + } + + $arena = $this->document->arena(); + + if ($direction < 0) { + return $this->siblingHandle($arena->previous($this->arenaId)); + } + + return $this->siblingHandle($arena->next($this->arenaId)); + } + + protected function siblingHandle($sibling) + { + if ($sibling === null) { + return null; + } + + return $this->document->handle($sibling); + } + + protected function assertMutableSibling() + { + if (isset($this->document->arena()->embeddedOwner[$this->arenaId])) { + throw new \LogicException('Antlers regions embedded in HTML markup cannot be moved as DOM children. Mutate the owning node instead.'); + } + + $parent = $this->parent(); + + if ($parent === null) { + throw new \LogicException('Detached HTML graph nodes cannot be mutated relative to siblings.'); + } + + if (! $parent->hasChild($this)) { + throw new \LogicException('Antlers regions embedded in HTML markup cannot be moved as DOM children. Mutate the owning node instead.'); + } + } + + protected function touchDocument() + { + $this->touchArenaNode($this->arenaId); + } + + protected function touchArenaNode($arenaId) + { + if ($arenaId !== null && $this->document->arena()->parent($arenaId) !== null) { + $this->document->markDirty(); + } + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/NodeSequence.php b/src/View/Antlers/Language/Analyzers/Html/NodeSequence.php new file mode 100644 index 00000000000..759d4ef707f --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/NodeSequence.php @@ -0,0 +1,198 @@ + */ + protected $iterate; + + protected function __construct(Document $document, callable $iterate) + { + $this->document = $document; + $this->iterate = $iterate; + } + + public static function childrenOf(Document $document, $parent) + { + return new self($document, function ($reverse) use ($document, $parent) { + $arena = $document->arena(); + $node = self::firstChildForDirection($arena, $parent, $reverse); + + while ($node !== null) { + yield $document->handle($node); + $node = self::nextArenaSibling($arena, $node, $reverse); + } + }); + } + + public static function between(AntlersNode $opening, AntlersNode $closing) + { + if ($opening->document() !== $closing->document()) { + throw new \InvalidArgumentException('HTML sequence boundaries must belong to the same document.'); + } + + $document = $opening->document(); + + return new self($document, function ($reverse) use ($opening, $closing) { + [$boundary, $node] = self::regionTraversalStart($opening, $closing, $reverse); + + while ($node !== $boundary) { + if ($node === null) { + throw new \LogicException('HTML sequence boundaries must be ordered siblings.'); + } + + yield $node; + $node = self::nextObjectSibling($node, $reverse); + } + }); + } + + protected static function firstChildForDirection($arena, $parent, $reverse) + { + if ($reverse) { + return $arena->lastChild($parent); + } + + return $arena->firstChild($parent); + } + + protected static function nextArenaSibling($arena, $node, $reverse) + { + if ($reverse) { + return $arena->previous($node); + } + + return $arena->next($node); + } + + protected static function regionTraversalStart($opening, $closing, $reverse) + { + if ($reverse) { + return [$opening, $closing->previousSibling()]; + } + + return [$closing, $opening->nextSibling()]; + } + + protected static function nextObjectSibling($node, $reverse) + { + if ($reverse) { + return $node->previousSibling(); + } + + return $node->nextSibling(); + } + + public function document() + { + return $this->document; + } + + /** @return LazyCollection */ + public function nodes() + { + return LazyCollection::make(fn () => ($this->iterate)(false)); + } + + /** @return LazyCollection */ + public function elements($deep = true) + { + return LazyCollection::make(function () use ($deep) { + foreach ($this->nodes() as $node) { + if (! $node instanceof Element) { + continue; + } + + yield $node; + + if ($deep) { + yield from $node->content()->elements(); + } + } + })->values(); + } + + /** + * @param callable(string, string, Text): (string|null) $rewrite + * @param (callable(Element): bool)|null $descend + */ + public function rewriteTextEdges(callable $rewrite, ?callable $descend = null) + { + $this->rewriteEdge(TextEdge::LEADING, $rewrite, $descend); + $this->rewriteEdge(TextEdge::TRAILING, $rewrite, $descend); + + return $this; + } + + /** + * @param callable(string, string, Text): (string|null) $rewrite + * @param (callable(Element): bool)|null $descend + */ + public function rewriteLeadingTextEdge(callable $rewrite, ?callable $descend = null) + { + $this->rewriteEdge(TextEdge::LEADING, $rewrite, $descend); + + return $this; + } + + /** + * @param callable(string, string, Text): (string|null) $rewrite + * @param (callable(Element): bool)|null $descend + */ + public function rewriteTrailingTextEdge(callable $rewrite, ?callable $descend = null) + { + $this->rewriteEdge(TextEdge::TRAILING, $rewrite, $descend); + + return $this; + } + + protected function rewriteEdge($edge, callable $rewrite, ?callable $descend = null) + { + $reverse = $edge === TextEdge::TRAILING; + + foreach (($this->iterate)($reverse) as $node) { + if ($node instanceof Text) { + $content = $node->content(); + $replacement = $rewrite($content, $edge, $node); + + if ($replacement !== null && $replacement !== $content) { + $node->content((string) $replacement); + $content = (string) $replacement; + } + + if ($content !== '') { + return true; + } + + continue; + } + + if ($node instanceof Element) { + if (Document::isVoidElement($node->name()) + || ($descend !== null && ! $descend($node))) { + return true; + } + + if ($node->content()->rewriteEdge($edge, $rewrite, $descend)) { + return true; + } + + continue; + } + + if ($node instanceof AntlersNode) { + return true; + } + + } + + return false; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/OpaqueNode.php b/src/View/Antlers/Language/Analyzers/Html/OpaqueNode.php new file mode 100644 index 00000000000..cbd68fba276 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/OpaqueNode.php @@ -0,0 +1,69 @@ +source = $source; + $this->type = $type; + } + + public static function fromArena(Document $document, $id) + { + $arena = $document->arena(); + $node = new self($document, $arena->value[$id], $arena->opaqueType[$id]); + $node->arenaId = $id; + + return $node; + } + + public function type() + { + return $this->type; + } + + public function source($source = null) + { + if (func_num_args() === 0) { + return $this->source; + } + + $this->source = (string) $source; + + if ($this->arenaId !== null) { + $this->document->arena()->value[$this->arenaId] = $this->source; + } + $this->touchDocument(); + + return $this; + } + + public function embeddedNodes() + { + return array_map([$this->document, 'handle'], $this->document->arena()->embedded[$this->arenaId] ?? []); + } + + public function siblingsFor(Node $node) + { + return $this->embeddedNodes(); + } + + public function hasChild(Node $node) + { + return in_array($node->arenaId(), $this->document->arena()->embedded[$this->arenaId] ?? [], true); + } + + protected function render() + { + return $this->source; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Region.php b/src/View/Antlers/Language/Analyzers/Html/Region.php new file mode 100644 index 00000000000..62655ced2ed --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Region.php @@ -0,0 +1,229 @@ +opening = $opening; + $this->closing = $closing; + } + + public function opening() + { + return $this->opening; + } + + public function closing() + { + return $this->closing; + } + + public function document() + { + return $this->opening->document(); + } + + public function name() + { + $name = $this->opening->antlersNode()->name; + + if ($name === null) { + return null; + } + + return $name->compound; + } + + /** @return ParameterNode|null */ + public function parameter($name) + { + return $this->opening->antlersNode()->parameter($name); + } + + public function staticParameterValue($name, $default = null) + { + try { + return $this->opening->antlersNode()->staticParameterValue($name, $default); + } catch (\InvalidArgumentException $exception) { + throw new \InvalidArgumentException(sprintf( + 'The [%s] parameter on the [%s] HTML region must be static.', + $name, + $this->name() + ), 0, $exception); + } + } + + /** @return NodeSequence|$this */ + public function content($content = null) + { + $this->assertOrderedSiblings(); + + if (func_num_args() !== 0) { + if ($content === $this->opening || $content === $this->closing) { + throw new \LogicException('HTML region boundaries cannot become region content.'); + } + + $existing = NodeSequence::between($this->opening, $this->closing) + ->nodes() + ->all(); + + $this->closing->before($content); + + foreach ($existing as $node) { + if ($node !== $content) { + $node->remove(); + } + } + + return $this; + } + + return NodeSequence::between($this->opening, $this->closing); + } + + /** + * @param callable(string, string, Text): (string|null) $rewrite + * @param (callable(Element): bool)|null $descend + */ + public function rewriteTextEdges(callable $rewrite, ?callable $descend = null) + { + $this->content()->rewriteTextEdges($rewrite, $descend); + + return $this; + } + + /** @return LazyCollection */ + public function ownedElements() + { + return LazyCollection::make(fn () => $this->ownedElementsWithin($this->content()))->values(); + } + + public function unwrap() + { + $this->assertOrderedSiblings(); + $this->closing->remove(); + $this->opening->remove(); + + return $this; + } + + /** @return $this */ + public function wrapWith(Element $element) + { + if ($element->document() !== $this->document()) { + throw new \InvalidArgumentException('HTML graph nodes cannot be moved between documents.'); + } + + $this->assertOrderedSiblings(); + + $members = [ + $this->opening, + ...$this->content()->nodes()->all(), + $this->closing, + ]; + + if (in_array($element, $members, true)) { + throw new \LogicException('An HTML region cannot be wrapped with one of its own members.'); + } + + $this->opening->before($element); + + foreach ($members as $member) { + $element->append($member); + } + + return $this; + } + + protected function ownedElementsWithin(NodeSequence $sequence) + { + $nestedClosing = null; + + foreach ($sequence->nodes() as $node) { + if ($nestedClosing !== null) { + if ($node === $nestedClosing) { + $nestedClosing = null; + } + + continue; + } + + if ($node instanceof AntlersNode) { + $candidate = $node->antlersNode(); + + if ($this->isNestedRegionOpening($candidate)) { + $closing = $candidate->isClosedBy->htmlNode(); + + if ($closing instanceof AntlersNode + && $closing->parent() === $node->parent()) { + $nestedClosing = $closing; + + continue; + } + } + } + + if (! $node instanceof Element) { + continue; + } + + yield $node; + yield from $this->ownedElementsWithin($node->content()); + } + } + + protected function isNestedRegionOpening($node) + { + if (! $node instanceof LanguageAntlersNode) { + return false; + } + + if ($node->isClosingTag) { + return false; + } + + if (! $node->isClosedBy instanceof LanguageAntlersNode) { + return false; + } + + if ($node->name === null) { + return false; + } + + return strcasecmp($node->name->compound, (string) $this->name()) === 0; + } + + protected function assertOrderedSiblings() + { + $parent = $this->opening->parent(); + + if ($parent === null || $parent !== $this->closing->parent()) { + throw new \LogicException('HTML regions must begin and end as children of the same container.'); + } + + if (! $parent->hasChild($this->opening) || ! $parent->hasChild($this->closing)) { + throw new \LogicException('HTML regions must begin and end as children of the same container.'); + } + + for ($node = $this->opening->nextSibling(); $node !== null; $node = $node->nextSibling()) { + if ($node === $this->closing) { + return; + } + } + + throw new \LogicException('HTML region boundaries must be ordered siblings.'); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/SourceAnchor.php b/src/View/Antlers/Language/Analyzers/Html/SourceAnchor.php new file mode 100644 index 00000000000..e106c75d3a9 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/SourceAnchor.php @@ -0,0 +1,67 @@ +document->arena()->anchorTarget[$this->arenaId] ?? null; + + if ($target === null) { + return null; + } + + return $this->document->handle($target); + } + + public function replaceTarget(Node $target) + { + if ($target->document() !== $this->document || $target->arenaId() === null) { + throw new \InvalidArgumentException('Source-anchor targets must belong to the same HTML document.'); + } + + $this->releaseTarget(); + $target->anchorSourceAt($this); + + return $this; + } + + public function releaseTarget() + { + $arena = $this->document->arena(); + $target = $arena->anchorTarget[$this->arenaId] ?? null; + + if ($target !== null && ($arena->sourceAnchor[$target] ?? null) === $this->arenaId) { + unset($arena->sourceAnchor[$target]); + } + + $arena->anchorTarget[$this->arenaId] = null; + + if ($target !== null) { + $this->touchDocument(); + $this->touchArenaNode($target); + } + } + + public function releaseSourceAnchors() + { + $this->releaseTarget(); + } + + protected function render() + { + return $this->document->renderArenaNode($this->arenaId); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/TableMode.php b/src/View/Antlers/Language/Analyzers/Html/TableMode.php new file mode 100644 index 00000000000..9f9c26d4ecd --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/TableMode.php @@ -0,0 +1,16 @@ +content = $content; + } + + public static function fromArena(Document $document, $id) + { + $node = new self($document, $document->arena()->value[$id]); + $node->arenaId = $id; + + return $node; + } + + public function content($content = null) + { + if (func_num_args() === 0) { + return $this->content; + } + + $this->content = (string) $content; + + if ($this->arenaId !== null) { + $this->document->arena()->value[$this->arenaId] = $this->content; + } + $this->touchDocument(); + + return $this; + } + + protected function render() + { + return $this->content; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/TextEdge.php b/src/View/Antlers/Language/Analyzers/Html/TextEdge.php new file mode 100644 index 00000000000..ae9aa71f2b1 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/TextEdge.php @@ -0,0 +1,11 @@ +parser; } + /** + * @internal + * + * @return HtmlAntlersNode|null + */ + public function htmlNode() + { + if ($this->htmlContextOwner !== null) { + return $this->htmlContextOwner->htmlNode(); + } + + if ($this->htmlGraphNode === null && $this->describesCurrentParse()) { + $this->htmlGraphNode = $this->parser->html()->node($this); + } + + return $this->htmlGraphNode; + } + + /** @return bool */ + protected function describesCurrentParse() + { + if ($this->parser === null || $this->parserGeneration === null) { + return false; + } + + return $this->parserGeneration === $this->parser->generation(); + } + + /** @internal */ + public function withHtmlContextOwner(AntlersNode $owner) + { + $this->htmlContextOwner = $owner; + + foreach ($this->processedInterpolationRegions as $nodes) { + foreach ($nodes as $node) { + if ($node instanceof AntlersNode) { + $node->withHtmlContextOwner($owner); + } + } + } + + return $this; + } + + /** @internal */ + public function withHtmlNode(HtmlAntlersNode $node) + { + $this->htmlGraphNode = $node; + + return $this; + } + + /** @internal */ + public function parentElement() + { + return $this->htmlNode()?->parentElement(); + } + + /** @internal */ + public function htmlContext() + { + if ($this->htmlContextOwner !== null) { + return $this->htmlContextOwner->htmlContext(); + } + + if ($this->htmlContext === null && $this->describesCurrentParse()) { + $this->parser->scanHtmlContext(); + } + + return $this->htmlContext; + } + + /** @internal */ + public function previousHtmlSibling() + { + return $this->htmlNode()?->previousSibling(); + } + + /** @internal */ + public function nextHtmlSibling() + { + return $this->htmlNode()?->nextSibling(); + } + /** * The node's identifier. * @@ -314,6 +405,9 @@ public function copyBasicDetailsTo($instance) $instance->endPosition = $this->endPosition; $instance->interpolationRegions = $this->interpolationRegions; $instance->processedInterpolationRegions = $this->processedInterpolationRegions; + $instance->htmlContextOwner = $this->htmlContextOwner; + $instance->htmlContext = $this->htmlContext; + $instance->parserGeneration = $this->parserGeneration; return $instance; } @@ -608,6 +702,35 @@ public function isPaired() return true; } + /** @return ParameterNode|null */ + public function parameter($name) + { + foreach ($this->parameters as $parameter) { + if (strcasecmp($parameter->name, $name) === 0) { + return $parameter; + } + } + + return null; + } + + public function staticParameterValue($name, $default = null) + { + $parameter = $this->parameter($name); + + if ($parameter === null) { + return $default; + } + + if (! $parameter->isStatic()) { + throw new \InvalidArgumentException( + sprintf('The [%s] Antlers parameter must be static.', $name) + ); + } + + return $parameter->value; + } + public function getNodeDocumentText() { return $this->parser->getText($this->startPosition->index, $this->endPosition->index + 1); diff --git a/src/View/Antlers/Language/Nodes/Parameters/ParameterNode.php b/src/View/Antlers/Language/Nodes/Parameters/ParameterNode.php index fead10a899e..f4ad47c85ca 100644 --- a/src/View/Antlers/Language/Nodes/Parameters/ParameterNode.php +++ b/src/View/Antlers/Language/Nodes/Parameters/ParameterNode.php @@ -28,4 +28,9 @@ class ParameterNode extends AbstractNode * @var AntlersNode|null */ public $parent = null; + + public function isStatic() + { + return ! $this->isVariableReference && empty($this->interpolations); + } } diff --git a/src/View/Antlers/Language/Parser/ComponentCompiler.php b/src/View/Antlers/Language/Parser/ComponentCompiler.php index 83225a463cb..d6ee253ce19 100644 --- a/src/View/Antlers/Language/Parser/ComponentCompiler.php +++ b/src/View/Antlers/Language/Parser/ComponentCompiler.php @@ -4,6 +4,7 @@ use Illuminate\Support\Str; use Statamic\View\Antlers\Language\Parser\Concerns\CompilesBladeComponents; +use Statamic\View\Instrumentation\Antlers\ComponentSourceMap; use Stillat\BladeParser\Nodes\Components\ComponentNode; use Stillat\BladeParser\Parser\DocumentParser; @@ -13,12 +14,24 @@ class ComponentCompiler protected array $statamicTags = ['statamic', 's', 'flux']; - public function compile($template) + protected ?ComponentSourceMap $sourceMap = null; + + public function sourceMap(): ?ComponentSourceMap + { + return $this->sourceMap; + } + + public function compile($template, bool $mapSource = false) { + $this->sourceMap = null; if (! Str::contains($template, ['sourceMap = new ComponentSourceMap($template); + } + return (new DocumentParser()) ->registerCustomComponentTags($this->statamicTags) ->onlyParseComponents() @@ -56,11 +69,22 @@ protected function compileNodes($nodes) protected function compileComponent(ComponentNode $component) { if ($component->isSelfClosing) { - return "{{ %$component->innerContent /}}"; + return $this->mapComponentMarkup($component, "{{ %$component->innerContent /}}"); } $innerContent = $this->compileNodes($component->getNodes()); - return "{{ %$component->innerContent }}$innerContent{{ /%$component->innerContent }}"; + return $this->mapComponentMarkup($component, "{{ %$component->innerContent }}") + .$innerContent + .$this->mapComponentMarkup($component->isClosedBy, "{{ /%$component->innerContent }}"); + } + + protected function mapComponentMarkup(?ComponentNode $node, string $compiled): string + { + if ($node?->position !== null) { + $this->sourceMap?->replace($node->position->startOffset, $node->position->endOffset + 1, $compiled); + } + + return $compiled; } } diff --git a/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php b/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php index 5fed964f1c2..8a6c7a4312a 100644 --- a/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php +++ b/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php @@ -9,6 +9,20 @@ trait CompilesBladeComponents { + public const COMPONENT_PROXY_PREFIX = '%component_proxy:'; + + /** + * @param string $content + * @return bool + */ + public static function isComponentProxyContent($content) + { + $content = ltrim($content); + + return str_starts_with($content, self::COMPONENT_PROXY_PREFIX) + || str_starts_with($content, '/'.self::COMPONENT_PROXY_PREFIX); + } + protected function getComponentName(ComponentNode $componentNode) { $name = $componentNode->name; @@ -34,19 +48,21 @@ protected function compileBladeComponent(ComponentNode $component, string $prefi $params .= ' component_name___="'.$name.'"'; } + $proxy = self::COMPONENT_PROXY_PREFIX; + if ($component->isClosingTag && ! $component->isSelfClosing) { - return "{{ /%component_proxy:{$tagMethod} }}"; + return $this->mapComponentMarkup($component, "{{ /{$proxy}{$tagMethod} }}"); } if ($component->isSelfClosing) { - return "{{ %component_proxy:$tagMethod $params /}}"; + return $this->mapComponentMarkup($component, "{{ {$proxy}$tagMethod $params /}}"); } - $open = "{{ %component_proxy:$tagMethod $params }}"; + $open = $this->mapComponentMarkup($component, "{{ {$proxy}$tagMethod $params }}"); $innerContent = $this->compileNodes($component->getNodes()); - $close = "{{ /%component_proxy:$tagMethod }}"; + $close = $this->mapComponentMarkup($component->isClosedBy, "{{ /{$proxy}$tagMethod }}"); return $open.$innerContent.$close; } diff --git a/src/View/Antlers/Language/Parser/DocumentParser.php b/src/View/Antlers/Language/Parser/DocumentParser.php index bb0b214d5b9..dfd5a48847e 100644 --- a/src/View/Antlers/Language/Parser/DocumentParser.php +++ b/src/View/Antlers/Language/Parser/DocumentParser.php @@ -3,6 +3,7 @@ namespace Statamic\View\Antlers\Language\Parser; use Statamic\Support\Str; +use Statamic\View\Antlers\Language\Analyzers\Html\Document; use Statamic\View\Antlers\Language\Analyzers\RecursiveParentAnalyzer; use Statamic\View\Antlers\Language\Analyzers\TagPairAnalyzer; use Statamic\View\Antlers\Language\Errors\AntlersErrorCodes; @@ -22,6 +23,7 @@ use Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract; use Statamic\View\Antlers\Language\Utilities\CharacterOffsets; use Statamic\View\Antlers\Language\Utilities\StringUtilities; +use Statamic\View\Instrumentation\Antlers\ContextScanner; class DocumentParser { @@ -97,8 +99,25 @@ class DocumentParser private $nodes = []; private $renderNodes = []; + /** @var Document|null */ + private $htmlDocument = null; + + /** @var int */ + private $generation = 0; + + /** @var bool */ + private $htmlContextScanned = false; + + /** @var bool */ + private $annotateHtmlContext = false; + private $isInterpolatedParser = false; + /** @var string */ + private $originalContent = ''; + + private $inheritRuntimeLineSeed = true; + private $inputLen = 0; private $documentOffsets = []; private $documentOffsetKeys = []; @@ -138,6 +157,9 @@ class DocumentParser */ protected $visitors = []; + /** @var NodeVisitorContract[] */ + private $runtimeVisitors = []; + public function __construct() { $this->nodeParser = new AntlersNodeParser(); @@ -174,6 +196,78 @@ public function setIsVirtual($isVirtual) $this->isVirtual = $isVirtual; } + public function inheritRuntimeLineSeed($inherit = true) + { + $this->inheritRuntimeLineSeed = $inherit; + + return $this; + } + + /** + * @internal + * + * @param bool $annotate + * @return $this + */ + public function annotateHtmlContext($annotate = true) + { + $this->annotateHtmlContext = $annotate; + + return $this; + } + + /** @internal */ + public function annotatesHtmlContext(): bool + { + return $this->annotateHtmlContext || ContextScanner::$enabled; + } + + /** + * @internal + * + * @return int + */ + public function generation() + { + return $this->generation; + } + + /** + * @internal + * + * @return void + */ + public function scanHtmlContext() + { + if ($this->htmlContextScanned) { + return; + } + + $this->htmlContextScanned = true; + + (new ContextScanner)->annotate($this->nodes, $this); + } + + /** @internal */ + public function html() + { + if ($this->htmlDocument === null) { + $this->htmlDocument = Document::fromParser($this); + } + + return $this->htmlDocument; + } + + /** + * @internal + * + * @return string + */ + public function getOriginalContent() + { + return $this->originalContent; + } + /** * @param int $start * @param int|null $length @@ -409,6 +503,8 @@ public function getRenderNodes() */ private function processInputText($input, ?array $prefix) { + $this->originalContent = $input; + if ($prefix === null) { $this->content = StringUtilities::normalizeLineEndings($input); $this->inputLen = mb_strlen($this->content); @@ -954,6 +1050,12 @@ private function parseDocument($text, ?array $interpolationPrefix) $parseResults = $interpolationNode === null ? [] : [$interpolationNode]; $node->processedInterpolationRegions[$varName] = $parseResults; + + foreach ($parseResults as $interpolationNode) { + if ($interpolationNode instanceof AntlersNode) { + $interpolationNode->withHtmlContextOwner($node); + } + } } $node->hasProcessedInterpolationRegions = true; } @@ -992,6 +1094,8 @@ private function parseDocument($text, ?array $interpolationPrefix) } foreach ($this->nodes as $node) { + $node->parserGeneration = $this->generation; + if ($node instanceof AntlersNode) { $node->isInterpolationNode = $this->isInterpolatedParser; } @@ -1017,11 +1121,21 @@ private function parseDocument($text, ?array $interpolationPrefix) } } - if (! empty($this->visitors)) { - foreach ($this->visitors as $visitor) { - foreach ($this->renderNodes as $node) { - $visitor->visit($node); - } + if ($this->annotatesHtmlContext()) { + $this->scanHtmlContext(); + } + + $visitors = $this->visitors; + + foreach ($this->runtimeVisitors as $visitor) { + if (! in_array($visitor, $visitors, true)) { + $visitors[] = $visitor; + } + } + + foreach ($visitors as $visitor) { + foreach ($this->renderNodes as $node) { + $visitor->visit($node); } } @@ -1035,15 +1149,32 @@ private function parseDocument($text, ?array $interpolationPrefix) */ public function addVisitor(NodeVisitorContract $visitor) { + foreach ($this->visitors as $registered) { + if ($registered === $visitor) { + return; + } + } + $this->visitors[] = $visitor; } + /** + * @internal + * + * @param NodeVisitorContract[] $visitors + */ + public function setRuntimeVisitors(array $visitors): void + { + $this->runtimeVisitors = $visitors; + } + /** * Clears all registered NodeVisitorContract instances. */ public function clearVisitors() { $this->visitors = []; + $this->runtimeVisitors = []; } private function scanToEndOfPhpRegion($checkChar) @@ -1765,6 +1896,9 @@ protected function resetIntermediateState() public function resetState() { + $this->htmlDocument = null; + $this->htmlContextScanned = false; + $this->generation++; $this->charLen = 0; $this->antlersStartIndex = []; $this->antlersStartPositionIndex = []; @@ -1773,7 +1907,7 @@ public function resetState() $this->renderNodes = []; $this->nodes = []; - if (! empty(GlobalRuntimeState::$globalTagEnterStack)) { + if ($this->inheritRuntimeLineSeed && ! empty(GlobalRuntimeState::$globalTagEnterStack)) { /** @var AntlersNode $lastTagNode */ $lastTagNode = GlobalRuntimeState::$globalTagEnterStack[count(GlobalRuntimeState::$globalTagEnterStack) - 1]; diff --git a/src/View/Antlers/Language/Runtime/ConditionProcessor.php b/src/View/Antlers/Language/Runtime/ConditionProcessor.php index bc1c30c33e5..667e2303820 100644 --- a/src/View/Antlers/Language/Runtime/ConditionProcessor.php +++ b/src/View/Antlers/Language/Runtime/ConditionProcessor.php @@ -38,7 +38,7 @@ public function process(ConditionNode $node, $data) if ($branch->head->name->name == 'else') { // For consistency. if ($this->processor->isTracingEnabled()) { - $this->processor->getRuntimeConfiguration()->traceManager->traceOnEnter($branch->head); + $this->processor->getRuntimeConfiguration()->traceManager->traceOnEnter($branch->head, $this->processor); } if (GlobalDebugManager::$isConnected && GlobalDebugManager::$activeSessionLocator != null) { @@ -66,7 +66,7 @@ public function process(ConditionNode $node, $data) $interpolationReplacements = []; if ($this->processor->isTracingEnabled()) { - $this->processor->getRuntimeConfiguration()->traceManager->traceOnEnter($branch->head); + $this->processor->getRuntimeConfiguration()->traceManager->traceOnEnter($branch->head, $this->processor); } if (GlobalDebugManager::$isConnected && GlobalDebugManager::$activeSessionLocator != null) { diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 59f964c51b9..aadb4f4c869 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -55,6 +55,7 @@ use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Antlers\SyntaxError; use Statamic\View\Cascade; +use Statamic\View\Instrumentation\InstrumentationState; use Statamic\View\Slot; use Statamic\View\State\CachesOutput; use Throwable; @@ -208,6 +209,9 @@ class NodeProcessor */ private $activeNode = null; + /** @var int */ + private $traceReduceDepth = 0; + /** * A list of all valid PHP opening tags. * @@ -378,6 +382,18 @@ public function setIsProvidingParameterContent($isParameter) return $this; } + /** @return bool */ + public function triggerRenderStart() + { + if (! $this->isTracingEnabled()) { + return false; + } + + $this->runtimeConfiguration->traceManager->traceRenderStart(); + + return true; + } + /** * Triggers a runtime trace complete interrupt. */ @@ -1177,6 +1193,39 @@ private function clearInterpolationCache() * @throws TagNotFoundException */ public function reduce($processNodes) + { + $processorDepth = ++$this->traceReduceDepth; + $renderFailure = null; + + try { + return $this->reduceNodes($processNodes); + } catch (Throwable $throwable) { + $renderFailure = $throwable; + + throw $throwable; + } finally { + $cleanupFailure = null; + + if ($this->runtimeConfiguration?->traceManager !== null) { + try { + $this->runtimeConfiguration->traceManager + ->traceProcessorScopeComplete($this, $processorDepth); + } catch (Throwable $throwable) { + if ($renderFailure === null) { + $cleanupFailure = $throwable; + } + } + } + + $this->traceReduceDepth--; + + if ($cleanupFailure !== null) { + throw $cleanupFailure; + } + } + } + + protected function reduceNodes($processNodes) { $buffer = ''; $processStack = [[$processNodes, 0]]; @@ -1200,12 +1249,20 @@ public function reduce($processNodes) $this->activeNode = $node; if ($this->isTracingEnabled()) { - $this->runtimeConfiguration->traceManager->traceOnEnter($node); + $this->runtimeConfiguration->traceManager->traceOnEnter( + $node, + $this, + $this->traceReduceDepth + ); } if ($node instanceof AntlersNode) { if ($node->name != null) { if ($node->name->name == 'elseif' || $node->name->name == 'if') { + if ($this->isTracingEnabled()) { + $this->runtimeConfiguration->traceManager->traceOnExit($node, null); + } + continue; } @@ -1367,6 +1424,10 @@ public function reduce($processNodes) continue; } else { + if ($this->isTracingEnabled()) { + $this->runtimeConfiguration->traceManager->traceOnExit($node, null); + } + if ($result instanceof ExecutionBranch) { $processStack[] = [$nodes, $i + 1]; $processStack[] = [$result->nodes, 0]; @@ -1437,6 +1498,7 @@ public function reduce($processNodes) $recursiveProcessor = $this->cloneProcessor(); $recursiveProcessor->setRuntimeAssignments($this->runtimeAssignments); + $recursiveBuffer = ''; // Substitute the current node with the original parent. foreach ($children as $childData) { @@ -1484,13 +1546,11 @@ public function reduce($processNodes) } } - $buffer .= $this->measureBufferAppend($node, $result); - - if ($this->isTracingEnabled()) { - $this->runtimeConfiguration->traceManager->traceOnExit($node, $result); - } + $recursiveBuffer .= $result; } + $buffer .= $this->measureBufferAppend($node, $recursiveBuffer); + $recursiveParent->activeDepth -= 1; RecursiveNodeManager::releaseRecursiveNode($node); @@ -1663,7 +1723,14 @@ public function reduce($processNodes) } try { - $output = call_user_func([$tag, $methodToCall], ...$args); + if ($this->antlersParser instanceof RuntimeParser && $this->antlersParser->hasHtmlInstrumentation()) { + $output = InstrumentationState::whileRenderingInContext( + $node->htmlContext(), + fn () => call_user_func([$tag, $methodToCall], ...$args) + ); + } else { + $output = call_user_func([$tag, $methodToCall], ...$args); + } if ($isCacheTag) { GlobalRuntimeState::$isCacheEnabled = false; @@ -1920,10 +1987,6 @@ public function reduce($processNodes) $buffer .= $this->measureBufferAppend($node, $results); - if ($this->isTracingEnabled()) { - $this->runtimeConfiguration->traceManager->traceOnExit($node, $results); - } - continue; } @@ -2085,9 +2148,7 @@ public function reduce($processNodes) } $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($runtimeResult)); - } - - if ($this->isTracingEnabled()) { + } elseif ($this->isTracingEnabled()) { $this->runtimeConfiguration->traceManager->traceOnExit($node, null); } @@ -2461,9 +2522,7 @@ public function reduce($processNodes) $buffer .= $this->measureBufferAppend($node, $loopBuffer); } - } - - if ($this->isTracingEnabled()) { + } elseif ($this->isTracingEnabled()) { $this->runtimeConfiguration->traceManager->traceOnExit($node, null); } @@ -2491,9 +2550,6 @@ public function reduce($processNodes) $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($val)); - if ($this->isTracingEnabled()) { - $this->runtimeConfiguration->traceManager->traceOnExit($node, null); - } $this->data = $lockData; continue; diff --git a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php index 8226380984f..91d1c4d313e 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php +++ b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php @@ -133,6 +133,9 @@ class RuntimeConfiguration */ public $allowMethodsInUserContent = false; + /** @var bool */ + public $annotateHtmlContext = false; + /** * Registers a new Antlers preparser callback. * @@ -140,9 +143,22 @@ class RuntimeConfiguration */ public function preparse(callable $callable) { + if (in_array($callable, $this->preparsers, true)) { + return; + } + $this->preparsers[] = $callable; } + /** @return void */ + public function removePreparser(callable $callable) + { + $this->preparsers = array_values(array_filter( + $this->preparsers, + fn ($preparser) => $preparser !== $callable + )); + } + /** * Gets all registered Antlers preparsers. * @@ -153,6 +169,32 @@ public function getPreparsers() return $this->preparsers; } + /** @return bool */ + public function hasRegisteredTracers() + { + if ($this->traceManager === null) { + return false; + } + + if ($this->traceManager->tracers() !== []) { + return true; + } + + return $this->traceManager->spanTracers() !== []; + } + + /** @return TraceManager */ + public function enableTracing() + { + if ($this->traceManager === null) { + $this->traceManager = new TraceManager(); + } + + $this->isTracingEnabled = true; + + return $this->traceManager; + } + /** * Registers a new NodeVisitorContract instance. * @@ -160,7 +202,12 @@ public function getPreparsers() */ public function addVisitor(NodeVisitorContract $visitor) { + if (in_array($visitor, $this->visitors, true)) { + return; + } + $this->visitors[] = $visitor; + RuntimeParser::clearRenderNodeCache(); } /** diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index f0b31e57d55..2827d3c5fe4 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -31,6 +31,8 @@ use Statamic\View\Antlers\Language\Runtime\Debugging\GlobalDebugManager; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Cascade; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Instrumentation\InstrumentationState; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; @@ -69,6 +71,12 @@ class RuntimeParser implements Parser */ private $allowPhp = false; + /** @var RuntimeConfiguration|null */ + protected $runtimeConfiguration; + + /** @var \Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract[] */ + protected $syncedVisitors = []; + /** * A list of pre-parsers. * @@ -138,6 +146,7 @@ public function __construct(DocumentParser $documentParser, NodeProcessor $nodeP */ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) { + $this->runtimeConfiguration = $configuration; GlobalRuntimeState::$allowPhpInContent = $configuration->allowPhpInUserContent; GlobalRuntimeState::$allowMethodsInContent = $configuration->allowMethodsInUserContent; GlobalRuntimeState::$throwErrorOnAccessViolation = $configuration->throwErrorOnAccessViolation; @@ -150,16 +159,9 @@ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) GlobalRuntimeState::$bannedContentModifierPaths = $configuration->guardedContentModifiers; GlobalRuntimeState::$allowedContentModifierPaths = $configuration->allowedContentModifiers; + $this->documentParser->annotateHtmlContext($configuration->annotateHtmlContext); $this->nodeProcessor->setRuntimeConfiguration($configuration); - foreach ($configuration->getPreparsers() as $preparser) { - $this->preparse($preparser); - } - - foreach ($configuration->getVisitors() as $visitor) { - $this->documentParser->addVisitor($visitor); - } - return $this; } @@ -170,6 +172,15 @@ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) */ public function resetRuntimeConfiguration() { + $this->documentParser->annotateHtmlContext(false); + + if ($this->syncedVisitors !== []) { + static::clearRenderNodeCache(); + } + + $this->documentParser->setRuntimeVisitors([]); + $this->syncedVisitors = []; + $this->runtimeConfiguration = null; $this->nodeProcessor->resetRuntimeConfiguration(); return $this; @@ -203,12 +214,67 @@ protected function sanitizePhp($text) protected function runPreParserCallbacks($text) { $value = $text; + $instrumenters = []; - foreach ($this->preParsers as $preParser) { + foreach ($this->mergeConfigurationCallbacks($this->preParsers, 'getPreparsers') as $preParser) { + if ($preParser instanceof HtmlInstrumentation) { + $instrumenters[] = $preParser; + + continue; + } + + $value = HtmlInstrumentation::instrumentTogether($value, $instrumenters); + $instrumenters = []; $value = call_user_func($preParser, $value); } - return $value; + return HtmlInstrumentation::instrumentTogether($value, $instrumenters); + } + + /** @internal */ + public function hasHtmlInstrumentation(): bool + { + foreach ($this->mergeConfigurationCallbacks($this->preParsers, 'getPreparsers') as $preparser) { + if ($preparser instanceof HtmlInstrumentation) { + return true; + } + } + + return false; + } + + /** + * @param callable[] $local + * @param string $accessor + * @return callable[] + */ + private function mergeConfigurationCallbacks(array $local, $accessor) + { + if ($this->runtimeConfiguration === null) { + return $local; + } + + $callbacks = $this->runtimeConfiguration->{$accessor}(); + + foreach ($local as $callback) { + if (! in_array($callback, $callbacks, true)) { + $callbacks[] = $callback; + } + } + + return $callbacks; + } + + protected function syncRuntimeVisitors() + { + if ($this->runtimeConfiguration === null) { + return; + } + + $visitors = $this->runtimeConfiguration->getVisitors(); + + $this->documentParser->setRuntimeVisitors($visitors); + $this->syncedVisitors = $visitors; } /** @@ -341,31 +407,49 @@ protected function shouldCacheRenderNodes($text) */ protected function renderText($text, $data = []) { - $text = $this->componentCompiler->compile($text); - $this->parseStack += 1; - $text = $this->runPreParserCallbacks($text); + $parseStackReleased = false; - if (! $this->canPossiblyParseAntlers($text)) { - $text = $this->sanitizePhp($text); + try { + $text = $this->componentCompiler->compile($text, $this->hasHtmlInstrumentation()); + $text = InstrumentationState::withSourceMap( + $this->componentCompiler->sourceMap(), + fn () => $this->runPreParserCallbacks($text) + ); - $this->parseStack -= 1; + if (! $this->canPossiblyParseAntlers($text)) { + $text = $this->sanitizePhp($text); - if ($text == null) { - return new AntlersString('', $this); - } + $this->parseStack -= 1; + $parseStackReleased = true; - return new AntlersString($text, $this); - } + if ($text == null) { + return new AntlersString('', $this); + } - $newLineStyle = StringUtilities::detectNewLineStyle($text); - $bufferContent = ''; + return new AntlersString($text, $this); + } - try { + $newLineStyle = StringUtilities::detectNewLineStyle($text); + $bufferContent = ''; $parseText = $this->sanitizePhp($text); + if ($this->runtimeConfiguration !== null) { + $this->documentParser->annotateHtmlContext($this->runtimeConfiguration->annotateHtmlContext); + } $cacheSlug = md5($parseText); + if ($this->documentParser->annotatesHtmlContext()) { + $cacheSlug .= ':html'; + } + $this->syncRuntimeVisitors(); if (! array_key_exists($cacheSlug, self::$standardRenderNodeCache) || ! $this->shouldCacheRenderNodes($text)) { + $previousParser = clone $this->documentParser; + foreach ($previousParser->getNodes() as $node) { + if ($node instanceof AntlersNode) { + $node->withParser($previousParser); + } + } + $this->documentParser->setIsVirtual($this->view == ''); if (strlen($this->view) > 0) { @@ -393,9 +477,42 @@ protected function renderText($text, $data = []) $this->nodeProcessor->cascade($this->cascade); $this->nodeProcessor->mergeRuntimeAssignments(GlobalRuntimeState::$tracedRuntimeAssignments); - $bufferContent = $this->nodeProcessor->render($renderNodes); - $this->nodeProcessor->triggerRenderComplete(); + $initialTraceManager = $this->runtimeConfiguration?->traceManager; + $initialTraceDepth = $initialTraceManager?->renderDepth() ?? 0; + $this->nodeProcessor->triggerRenderStart(); + $renderCompleted = false; + + try { + $bufferContent = $this->nodeProcessor->render($renderNodes); + + if ($newLineStyle != "\n") { + if (Str::contains($bufferContent, "\r\n") == false) { + $bufferContent = str_replace("\n", "\r\n", $bufferContent); + } + } + + $this->parseStack -= 1; + $parseStackReleased = true; + + if ($this->parseStack == 0 && GlobalRuntimeState::$containsLayout == false) { + $bufferContent = LiteralReplacementManager::processReplacements($bufferContent); + $bufferContent = StackReplacementManager::processReplacements($bufferContent); + + $bufferContent = str_replace(DocumentParser::getLeftBraceEscape(), DocumentParser::LeftBrace, $bufferContent); + $bufferContent = str_replace(DocumentParser::getRightBraceEscape(), DocumentParser::RightBrace, $bufferContent); + } + + if (GlobalRuntimeState::$containsLayout && $this->view == GlobalRuntimeState::$shareVariablesTemplateTrigger) { + GlobalRuntimeState::$layoutVariables = $this->nodeProcessor->getRuntimeAssignments(); + } + + $renderCompleted = true; + + return new AntlersString($bufferContent, $this); + } finally { + $this->completeRenderTracing($initialTraceManager, $initialTraceDepth, $renderCompleted); + } } catch (AntlersException $antlersException) { if ($this->isIgnitionInstalled()) { throw $this->buildAntlersExceptionError($antlersException, $text, $data); @@ -432,30 +549,44 @@ protected function renderText($text, $data = []) } throw $this->addAntlersErrorDetails($throwable, $text, $data); - } - - if ($newLineStyle != "\n") { - if (Str::contains($bufferContent, "\r\n") == false) { - $bufferContent = str_replace("\n", "\r\n", $bufferContent); + } finally { + if (! $parseStackReleased) { + $this->parseStack -= 1; } } + } - $this->parseStack -= 1; + private function completeRenderTracing(?Tracing\TraceManager $initialManager, int $initialDepth, bool $renderCompleted): void + { + $currentManager = $this->runtimeConfiguration?->traceManager; + $managers = []; - if ($this->parseStack == 0 && GlobalRuntimeState::$containsLayout == false) { - $bufferContent = LiteralReplacementManager::processReplacements($bufferContent); - $bufferContent = StackReplacementManager::processReplacements($bufferContent); + if ($initialManager !== null && $initialManager->renderDepth() > $initialDepth) { + $managers[] = $initialManager; + } - $bufferContent = str_replace(DocumentParser::getLeftBraceEscape(), DocumentParser::LeftBrace, $bufferContent); - $bufferContent = str_replace(DocumentParser::getRightBraceEscape(), DocumentParser::RightBrace, $bufferContent); + if ($currentManager !== null + && $currentManager !== $initialManager + && $currentManager->renderDepth() > 0) { + $managers[] = $currentManager; } - if (GlobalRuntimeState::$containsLayout && $this->view == GlobalRuntimeState::$shareVariablesTemplateTrigger) { - // Force the root runtime assignments to be merged into the global state. - GlobalRuntimeState::$layoutVariables = $this->nodeProcessor->getRuntimeAssignments(); + $failure = null; + + foreach ($managers as $manager) { + try { + $manager->traceRenderComplete(); + } catch (Throwable $throwable) { + + if ($renderCompleted) { + $failure ??= $throwable; + } + } } - return new AntlersString($bufferContent, $this); + if ($failure !== null) { + throw $failure; + } } private function cleanUpTempFiles() @@ -695,6 +826,9 @@ private function cloneRuntimeParser() $this->antlersLexer, $this->antlersParser ))->allowPhp($this->allowPhp); + $parser->runtimeConfiguration = $this->runtimeConfiguration; + $parser->syncedVisitors = &$this->syncedVisitors; + foreach ($this->preParsers as $preParser) { $parser->preparse($preParser); } diff --git a/src/View/Antlers/Language/Runtime/Tracing/ProcessorAwareTracerContract.php b/src/View/Antlers/Language/Runtime/Tracing/ProcessorAwareTracerContract.php new file mode 100644 index 00000000000..73070f03fe6 --- /dev/null +++ b/src/View/Antlers/Language/Runtime/Tracing/ProcessorAwareTracerContract.php @@ -0,0 +1,12 @@ +, processorId: int|null, processorDepth: int}> */ + protected $frames = []; + + /** @var int[] */ + protected $renderBoundaries = []; + + /** @var TracerContract[] */ + protected $renderCompletionTracers = []; + + /** @param RuntimeTracerContract|TracerContract $tracer */ + public function registerTracer($tracer) + { + if ($tracer instanceof RuntimeTracerContract) { + if (in_array($tracer, $this->tracers, true)) { + return; + } + + $this->tracers[] = $tracer; + + if ($tracer instanceof ProcessorAwareTracerContract) { + $this->processorAwareTracers[] = $tracer; + + if ($this->lastProcessor !== null) { + $tracer->setNodeProcessor($this->lastProcessor); + } + } + + return; + } + + if ($tracer instanceof TracerContract) { + if (in_array($tracer, $this->spanTracers, true)) { + return; + } + + $this->spanTracers[] = $tracer; + + return; + } + + throw new \InvalidArgumentException( + 'Tracers must implement RuntimeTracerContract or TracerContract.' + ); + } + + /** + * @param RuntimeTracerContract|TracerContract $tracer + * @return void + */ + public function removeTracer($tracer) { - $this->tracers[] = $tracer; + $this->tracers = array_values(array_filter( + $this->tracers, + fn ($registered) => $registered !== $tracer + )); + + $this->processorAwareTracers = array_values(array_filter( + $this->processorAwareTracers, + fn ($registered) => $registered !== $tracer + )); + + $this->spanTracers = array_values(array_filter( + $this->spanTracers, + fn ($registered) => $registered !== $tracer + )); } - public function traceOnEnter(AbstractNode $node) + /** @return RuntimeTracerContract[] */ + public function tracers() { + return $this->tracers; + } + + /** + * @internal + * + * @return TracerContract[] + */ + public function spanTracers() + { + return $this->spanTracers; + } + + public function traceOnEnter( + AbstractNode $node, + ?NodeProcessor $processor = null, + int $processorDepth = 0 + ) { + if ($this->renderBoundaries === []) { + $this->traceRenderStart(); + } + + if ($processor !== null && $processorDepth > 0) { + $this->closeProcessorScopeFrames($processor, $processorDepth); + } + + if ($processor !== null && $processor !== $this->lastProcessor && $this->processorAwareTracers !== []) { + foreach ($this->processorAwareTracers as $tracer) { + $tracer->setNodeProcessor($processor); + } + + $this->lastProcessor = $processor; + } + foreach ($this->tracers as $tracer) { $tracer->onEnter($node); } + + if ($this->spanTracers !== []) { + $span = Span::antlersNode($node, $processor, $processorDepth); + $handles = []; + + try { + foreach ($this->spanTracers as $tracer) { + $handles[] = [$tracer, $tracer->onEnter($span)]; + $this->rememberRenderCompletionTracer($tracer); + } + } catch (Throwable $enterFailure) { + try { + $this->closeHandles($span, $handles, null); + } catch (Throwable $cleanupFailure) { + // Preserve the original onEnter failure after closing every acquired handle. + } + + throw $enterFailure; + } + + $processorId = null; + + if ($processor !== null) { + $processorId = spl_object_id($processor); + } + + $this->frames[] = [ + 'nodeId' => spl_object_id($node), + 'span' => $span, + 'handles' => $handles, + 'processorId' => $processorId, + 'processorDepth' => $processorDepth, + ]; + } + } + + public function traceProcessorScopeComplete(NodeProcessor $processor, int $processorDepth): void + { + $this->closeProcessorScopeFrames($processor, $processorDepth); } public function traceOnExit(AbstractNode $node, $runtimeContent) { + $failure = null; + foreach ($this->tracers as $tracer) { - $tracer->onExit($node, $runtimeContent); + try { + $tracer->onExit($node, $runtimeContent); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + if ($this->frames === []) { + if ($failure !== null) { + throw $failure; + } + + return; + } + + $nodeId = spl_object_id($node); + $targetIndex = null; + + $boundary = $this->currentRenderBoundary(); + + for ($index = count($this->frames) - 1; $index >= $boundary; $index--) { + if ($this->frames[$index]['nodeId'] === $nodeId) { + $targetIndex = $index; + + break; + } + } + + if ($targetIndex === null) { + if ($failure !== null) { + throw $failure; + } + + return; + } + + while (count($this->frames) - 1 > $targetIndex) { + try { + $this->closeFrame(array_pop($this->frames), null); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + try { + $this->closeFrame(array_pop($this->frames), $runtimeContent); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + + if ($failure !== null) { + throw $failure; } } + public function traceRenderStart() + { + if ($this->renderBoundaries === []) { + $this->renderCompletionTracers = $this->spanTracers; + } + + $this->renderBoundaries[] = count($this->frames); + } + + public function renderDepth(): int + { + return count($this->renderBoundaries); + } + public function traceRenderComplete() { + $boundary = array_pop($this->renderBoundaries) ?? 0; + $failure = null; + + while (count($this->frames) > $boundary) { + try { + $this->closeFrame(array_pop($this->frames), null); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + foreach ($this->tracers as $tracer) { - $tracer->onRenderComplete(); + try { + $tracer->onRenderComplete(); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + if ($this->renderBoundaries !== []) { + if ($failure !== null) { + throw $failure; + } + + return; + } + + $this->lastProcessor = null; + $completionTracers = $this->renderCompletionTracers; + $this->renderCompletionTracers = []; + + foreach ($completionTracers as $tracer) { + try { + $tracer->onRenderComplete(); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + protected function rememberRenderCompletionTracer(TracerContract $tracer) + { + if (! in_array($tracer, $this->renderCompletionTracers, true)) { + $this->renderCompletionTracers[] = $tracer; } } + + /** @param array{nodeId: int, span: Span, handles: array, processorId: int|null, processorDepth: int} $frame */ + protected function closeFrame(array $frame, $runtimeContent) + { + $this->closeHandles($frame['span'], $frame['handles'], $runtimeContent); + } + + /** @param array $handles */ + protected function closeHandles(Span $span, array $handles, $runtimeContent) + { + $failure = null; + + for ($index = count($handles) - 1; $index >= 0; $index--) { + [$tracer, $handle] = $handles[$index]; + + try { + $tracer->onExit($span, $handle, $runtimeContent); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + protected function closeProcessorScopeFrames(NodeProcessor $processor, int $processorDepth): void + { + if ($this->frames === []) { + return; + } + + $processorId = spl_object_id($processor); + $boundary = $this->currentRenderBoundary(); + $failure = null; + + while (true) { + $targetIndex = null; + + for ($index = count($this->frames) - 1; $index >= $boundary; $index--) { + if ($this->frames[$index]['processorId'] === $processorId + && $this->frames[$index]['processorDepth'] === $processorDepth) { + $targetIndex = $index; + + break; + } + } + + if ($targetIndex === null) { + break; + } + + while (count($this->frames) > $targetIndex) { + try { + $this->closeFrame(array_pop($this->frames), null); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + } + + if ($failure !== null) { + throw $failure; + } + } + + protected function currentRenderBoundary() + { + if ($this->renderBoundaries === []) { + return 0; + } + + return end($this->renderBoundaries); + } } diff --git a/src/View/Antlers/Language/Utilities/CharacterOffsets.php b/src/View/Antlers/Language/Utilities/CharacterOffsets.php index ce18d5c0b02..1ade22055a3 100644 --- a/src/View/Antlers/Language/Utilities/CharacterOffsets.php +++ b/src/View/Antlers/Language/Utilities/CharacterOffsets.php @@ -147,6 +147,72 @@ protected static function toBytesByteLoop($source, array $characterOffsets) return $offsets; } + /** + * Map offsets in LF-normalized text back to the original UTF-8 source. + * CRLF counts as one normalized character and two authored characters. + * + * The first map is normalized character offset => source byte offset. + * The second map is source byte offset => source character offset. + * + * @param string $source + * @param int[] $characterOffsets + * @return array{0: array, 1: array} + */ + public static function normalizedToBytesAndCharacters($source, array $characterOffsets) + { + if ($characterOffsets === []) { + return [[], []]; + } + + $byteLength = strlen($source); + + if ($byteLength === mb_strlen($source) && strpos($source, "\r\n") === false) { + $bytes = self::identity($characterOffsets, $byteLength); + $characters = []; + + foreach ($bytes as $byte) { + $characters[$byte] = $byte; + } + + return [$bytes, $characters]; + } + + $needed = array_fill_keys($characterOffsets, true); + $bytes = []; + $characters = []; + $normalizedCharacter = 0; + $sourceCharacter = 0; + + for ($byte = 0; $byte < $byteLength; $byte++) { + if ((ord($source[$byte]) & 0xC0) === 0x80) { + // UTF-8 continuation bytes do not begin a new character. + continue; + } + + if (isset($needed[$normalizedCharacter]) && ! isset($bytes[$normalizedCharacter])) { + $bytes[$normalizedCharacter] = $byte; + $characters[$byte] = $sourceCharacter; + } + + if ($source[$byte] === "\r" && $byte + 1 < $byteLength && $source[$byte + 1] === "\n") { + $byte++; + $sourceCharacter++; + } + + $normalizedCharacter++; + $sourceCharacter++; + } + + foreach ($needed as $neededCharacter => $unused) { + if (! isset($bytes[$neededCharacter])) { + $bytes[$neededCharacter] = $byteLength; + $characters[$byteLength] = $sourceCharacter; + } + } + + return [$bytes, $characters]; + } + /** * @param string $source * @param int[] $byteOffsets diff --git a/src/View/Html/Concerns/QueriesElementBasics.php b/src/View/Html/Concerns/QueriesElementBasics.php new file mode 100644 index 00000000000..b4a82b7c9aa --- /dev/null +++ b/src/View/Html/Concerns/QueriesElementBasics.php @@ -0,0 +1,39 @@ +name(); + + return $tagName !== null && strcasecmp($tagName, $name) === 0; + } + + /** @return string[] */ + public function tokens(string $attribute): array + { + return HtmlSpec::classList($this->attr($attribute), $this->dynamicTokenDelimiters()); + } + + /** @return array */ + protected function dynamicTokenDelimiters(): array + { + return [['{{', '}}']]; + } + + /** @return string[] */ + public function classes(): array + { + return $this->tokens('class'); + } + + public function hasClass(string $class): bool + { + return in_array($class, $this->classes(), true); + } +} diff --git a/src/View/Html/ElementCollection.php b/src/View/Html/ElementCollection.php new file mode 100644 index 00000000000..262a9eaafe9 --- /dev/null +++ b/src/View/Html/ElementCollection.php @@ -0,0 +1,117 @@ + + */ +class ElementCollection extends Collection +{ + public function named(string ...$names): static + { + return $this->filter(function (Element $element) use ($names) { + foreach ($names as $name) { + if ($element->is($name)) { + return true; + } + } + + return $names === []; + }); + } + + public function withAttribute(string ...$names): static + { + return $this->filter(function (Element $element) use ($names) { + foreach ($names as $name) { + if (! $element->hasAttribute($name)) { + return false; + } + } + + return true; + }); + } + + public function withAnyAttribute(string ...$names): static + { + return $this->filter(function (Element $element) use ($names) { + foreach ($names as $name) { + if ($element->hasAttribute($name)) { + return true; + } + } + + return false; + }); + } + + public function missingAttribute(string ...$names): static + { + return $this->filter(function (Element $element) use ($names) { + foreach ($names as $name) { + if (! $element->hasAttribute($name)) { + return true; + } + } + + return false; + }); + } + + public function missingAllAttributes(string ...$names): static + { + return $this->reject(function (Element $element) use ($names) { + foreach ($names as $name) { + if ($element->hasAttribute($name)) { + return true; + } + } + + return false; + }); + } + + public function whereAttribute(string $name, ?string $value): static + { + return $this->filter( + fn (Element $element) => $element->hasAttribute($name) && $element->attr($name) === $value + ); + } + + public function withClass(string $class): static + { + return $this->filter(fn (Element $element) => $element->hasClass($class)); + } + + public function missingClass(string $class): static + { + return $this->reject(fn (Element $element) => $element->hasClass($class)); + } + + /** + * @return $this + * + * @throws MissingAttributeException + */ + public function ensureAttribute(string ...$names): static + { + if ($names === []) { + throw new InvalidArgumentException('At least one attribute name is required.'); + } + + $missing = $this->missingAttribute(...$names); + + if ($missing->isNotEmpty()) { + throw MissingAttributeException::forElements($missing, $names); + } + + return $this; + } +} diff --git a/src/View/Html/MissingAttributeException.php b/src/View/Html/MissingAttributeException.php new file mode 100644 index 00000000000..717f2095dea --- /dev/null +++ b/src/View/Html/MissingAttributeException.php @@ -0,0 +1,79 @@ +count(); + $described = $elements->take(3) + ->map(fn (Element $element) => static::describeElement($element)) + ->implode(', '); + $more = $count - 3; + + if ($more > 0) { + $described .= ", and {$more} more"; + } + + if (count($attributes) === 1) { + $label = 'attribute ['.$attributes[0].']'; + } else { + $label = 'attributes ['.implode(', ', $attributes).']'; + } + + $subject = 'elements are'; + + if ($count === 1) { + $subject = 'element is'; + } + + $exception = new static(sprintf( + '%d %s missing the required %s: %s.', + $count, + $subject, + $label, + $described + )); + + $exception->elements = $elements; + $exception->attributes = $attributes; + + return $exception; + } + + protected static function describeElement(Element $element) + { + $name = '<'.($element->name() ?? '{dynamic}').'>'; + + if ($element->isSynthetic()) { + return $name.' (synthesized)'; + } + + $line = $element->sourceLine(); + + if ($line === null) { + return $name; + } + + return "{$name} on line {$line}"; + } +} diff --git a/src/View/Instrumentation/Antlers/ComponentSourceMap.php b/src/View/Instrumentation/Antlers/ComponentSourceMap.php new file mode 100644 index 00000000000..d6b6d7777b5 --- /dev/null +++ b/src/View/Instrumentation/Antlers/ComponentSourceMap.php @@ -0,0 +1,115 @@ +replacements[$start] = [$end, mb_strlen($replacement)]; + } + + public function fingerprint(): string + { + return hash('xxh128', $this->source); + } + + /** + * @return array{0: array, 1: array} + */ + public function positions(array $starts, array $ends): array + { + $segments = $this->replacementSegments(); + $mappedStarts = $this->mapEndpoints($starts, $segments, 'authoredStart'); + $mappedEnds = $this->mapEndpoints($ends, $segments, 'authoredEnd'); + $positions = $this->authoredPositions(array_merge(array_values($mappedStarts), array_values($mappedEnds))); + + return [ + array_map(fn ($offset) => $positions[$offset], $mappedStarts), + array_map(fn ($offset) => $positions[$offset], $mappedEnds), + ]; + } + + /** @return array */ + private function replacementSegments(): array + { + ksort($this->replacements, SORT_NUMERIC); + $segments = []; + $delta = 0; + + foreach ($this->replacements as $start => [$end, $length]) { + $segments[] = [ + 'compiledStart' => $start + $delta, + 'compiledEnd' => $start + $delta + $length, + 'authoredStart' => $start, + 'authoredEnd' => $end, + ]; + $delta += $length - ($end - $start); + } + + return $segments; + } + + /** + * @param int[] $offsets + * @param array $segments + * @param 'authoredStart'|'authoredEnd' $boundary + * @return array Compiled character offset => authored normalized character offset. + */ + private function mapEndpoints(array $offsets, array $segments, string $boundary): array + { + sort($offsets, SORT_NUMERIC); + $mapped = []; + $index = 0; + $delta = 0; + + foreach ($offsets as $offset) { + while (isset($segments[$index]) && $offset >= $segments[$index]['compiledEnd']) { + $delta = $segments[$index]['compiledEnd'] - $segments[$index]['authoredEnd']; + $index++; + } + + $original = $offset - $delta; + + // An endpoint inside generated markup belongs to the corresponding + // edge of the authored component tag, not to an interpolated offset. + if (isset($segments[$index]) && $offset > $segments[$index]['compiledStart']) { + $original = $segments[$index][$boundary]; + } + + $mapped[$offset] = $original; + } + + return $mapped; + } + + /** @return array Keyed by authored normalized character offset. */ + private function authoredPositions(array $offsets): array + { + [$bytes, $characters] = CharacterOffsets::normalizedToBytesAndCharacters($this->source, $offsets); + $orderedBytes = array_values(array_unique($bytes)); + sort($orderedBytes, SORT_NUMERIC); + $positions = []; + $line = 1; + $previous = 0; + + foreach ($orderedBytes as $byte) { + $line += preg_match_all('/\r\n|\r|\n/', substr($this->source, $previous, $byte - $previous)); + $positions[$byte] = ['offset' => $characters[$byte], 'line' => $line]; + $previous = $byte; + } + + return array_map(fn ($byte) => $positions[$byte], $bytes); + } +} diff --git a/src/View/Instrumentation/Antlers/ContextScanner.php b/src/View/Instrumentation/Antlers/ContextScanner.php new file mode 100644 index 00000000000..5cdd07fc65c --- /dev/null +++ b/src/View/Instrumentation/Antlers/ContextScanner.php @@ -0,0 +1,402 @@ +> */ + protected $embeddedPositions = []; + + public function __construct() + { + $this->inspector = new ElementInspector; + } + + public function treatComponentsAsDynamic($dynamic = true) + { + $this->treatComponentsAsDynamic = $dynamic; + + return $this; + } + + /** + * @param string[] $prefixes + * @return $this + */ + public function withComponentPrefixes(array $prefixes) + { + $this->componentPrefixes = $prefixes; + + return $this; + } + + /** + * @param AbstractNode[] $nodes + * @param DocumentParser|null $parser + * @return void + */ + public function annotate($nodes, $parser = null) + { + $nodes = array_values($nodes); + + if ($parser === null) { + foreach ($nodes as $node) { + if ($node instanceof LanguageAntlersNode && $node->getParser() !== null) { + $parser = $node->getParser(); + + break; + } + } + } + + if ($parser === null) { + return; + } + + $hasAnnotatableNodes = false; + + foreach ($nodes as $node) { + if (! $node instanceof LiteralNode) { + $hasAnnotatableNodes = true; + + break; + } + } + + if (! $hasAnnotatableNodes) { + return; + } + + $document = $this->treatComponentsAsDynamic + ? Document::fromParser($parser, $this->componentPrefixes) + : $parser->html(); + $placements = $document->regionPlacements(); + $source = (string) $parser->getParsedContent(); + $documentHasExplicitStructure = HtmlSpec::hasExplicitDocumentStructure($source); + $this->classifyEmbeddedPlacements($placements, $source); + + $pending = []; + $byteOffsets = []; + + foreach ($nodes as $index => $node) { + if ($node instanceof LiteralNode) { + continue; + } + + $placement = null; + + if ($node instanceof LanguageAntlersNode) { + $placement = $placements[spl_object_id($node)] ?? null; + } + + [$context, $startByte] = $this->contextFromPlacement($placement, $document, $source); + $pending[$index] = [$context, $startByte]; + + if ($startByte !== null) { + $byteOffsets[] = $startByte; + } + } + + $characterOffsets = CharacterOffsets::toCharacters($source, $byteOffsets); + $proxyDepth = 0; + + foreach ($pending as $index => [$context, $startByte]) { + $node = $nodes[$index]; + + if ($proxyDepth > 0) { + $context = $this->dynamicComponentContext($context); + } else { + $context->elementStartOffset = null; + + if ($startByte !== null) { + $context->elementStartOffset = $characterOffsets[$startByte] ?? null; + } + } + + $context->documentHasExplicitStructure = $documentHasExplicitStructure; + $this->applyContext($node, $context); + + if ($node instanceof LanguageAntlersNode && $this->isComponentProxyNode($node)) { + if ($node->isClosingTag) { + $proxyDepth = max(0, $proxyDepth - 1); + } elseif (! $node->isSelfClosing) { + $proxyDepth++; + } + } + } + } + + /** + * @param array|null $placement + * @param string $source + * @return array{0: HtmlContext, 1: int|null} + */ + protected function contextFromPlacement($placement, Document $document, $source) + { + if ($placement === null) { + return [new HtmlContext(HtmlContext::KIND_ELEMENT_CONTENT), null]; + } + + $chain = $this->reportableChain($placement['chain']); + + switch ($placement['bucket']) { + case 'content': + case 'raw-text': + $context = new HtmlContext( + $this->contentKindForPlacement($placement['bucket']), + $this->innermostStaticName($chain), + null, + $chain, + null, + false, + $placement['reconstructed'] + ); + $context->fosterParented = $placement['fostered']; + + return [$context, $document->elementSourceStart($placement['parentElement'])]; + + case 'embedded': + [$kind, $attribute, $partial] = $this->classifyEmbedded($placement, $source); + $reportableOwner = $this->reportableName($placement['ownerName']); + + if ($this->shouldAppendDynamicOwner($placement['ownerName'], $reportableOwner, $chain)) { + $chain[] = HtmlContext::DYNAMIC_ELEMENT; + } + + $elementName = $this->embeddedElementName($kind, $partial, $reportableOwner); + + $context = new HtmlContext( + $this->tagKind($kind), + $elementName, + $attribute, + $chain, + null, + $placement['closing'] + ); + + return [$context, $document->elementSourceStart($placement['owner'])]; + + case 'opaque': + default: + $type = $placement['type']; + + if ($type === 'comment') { + $context = new HtmlContext(HtmlContext::KIND_COMMENT, null, null, $chain); + + return [$context, null]; + } + + if ($type !== 'markup') { + $context = new HtmlContext(HtmlContext::KIND_DOCTYPE, null, null, $chain); + + return [$context, null]; + } + + [$kind, $attribute, $partial] = $this->classifyEmbedded($placement, $source); + + $context = new HtmlContext( + $this->tagKind($kind), + $this->embeddedElementName($kind, $partial), + $attribute, + $chain, + null, + substr($source, $placement['markupStart'], 2) === ' $placement + * @param string $source + * @return array{0: string, 1: string|null, 2: string} + */ + protected function classifyEmbedded(array $placement, $source) + { + $relative = $placement['regionStart'] - $placement['markupStart']; + + return $this->embeddedPositions[$placement['markupStart']][$relative]; + } + + protected function classifyEmbeddedPlacements(array $placements, string $source): void + { + $groups = []; + + foreach ($placements as $placement) { + if ($placement['bucket'] !== 'embedded' + && ! ($placement['bucket'] === 'opaque' && $placement['type'] === 'markup')) { + continue; + } + + $start = $placement['markupStart']; + $groups[$start]['end'] = $placement['markupEnd']; + $groups[$start]['offsets'][] = $placement['regionStart'] - $start; + } + + $this->embeddedPositions = []; + + foreach ($groups as $start => $group) { + $this->embeddedPositions[$start] = $this->inspector->classifyTagPositions( + substr($source, $start, $group['end'] - $start), + $group['offsets'] + ); + } + } + + /** + * @param array $chain + * @return string[] + */ + protected function reportableChain(array $chain) + { + $reportable = []; + + foreach ($chain as $name) { + $reportable[] = $this->reportableName($name) ?? HtmlContext::DYNAMIC_ELEMENT; + } + + return $reportable; + } + + /** @return string|null */ + protected function reportableName($name) + { + if ($name === null) { + return null; + } + + if ($this->treatComponentsAsDynamic && ComponentPrefixes::matches($name, $this->componentPrefixes)) { + return null; + } + + return $name; + } + + /** + * @param string[] $chain + * @return string|null + */ + protected function innermostStaticName(array $chain) + { + if ($chain === []) { + return null; + } + + $innermost = $chain[count($chain) - 1]; + + if ($innermost === HtmlContext::DYNAMIC_ELEMENT) { + return null; + } + + return $innermost; + } + + protected function contentKindForPlacement($bucket) + { + if ($bucket === 'raw-text') { + return HtmlContext::KIND_RAW_TEXT; + } + + return HtmlContext::KIND_ELEMENT_CONTENT; + } + + protected function shouldAppendDynamicOwner($ownerName, $reportableOwner, array $chain) + { + if ($ownerName === null || $reportableOwner !== null) { + return false; + } + + return $chain === [] || $chain[count($chain) - 1] !== HtmlContext::DYNAMIC_ELEMENT; + } + + protected function embeddedElementName($kind, $partial, $fallback = null) + { + if ($kind === 'element-name' && $partial !== '') { + return $partial; + } + + return $fallback; + } + + /** + * @param string $kind A classifier kind from ElementInspector. + * @return string + */ + protected function tagKind($kind) + { + switch ($kind) { + case 'element-name': + return HtmlContext::KIND_ELEMENT_NAME; + case 'attribute-name': + return HtmlContext::KIND_ATTRIBUTE_NAME; + case 'attribute-value': + return HtmlContext::KIND_ATTRIBUTE_VALUE; + default: + return HtmlContext::KIND_TAG_OPEN; + } + } + + protected function dynamicComponentContext(HtmlContext $context) + { + $stack = $context->elementStack; + + if ($stack === [] || $stack[count($stack) - 1] !== HtmlContext::DYNAMIC_ELEMENT) { + $stack[] = HtmlContext::DYNAMIC_ELEMENT; + } + + return new HtmlContext( + HtmlContext::KIND_ELEMENT_CONTENT, + null, + null, + $stack + ); + } + + protected function isComponentProxyNode(LanguageAntlersNode $node) + { + return ComponentCompiler::isComponentProxyContent((string) $node->content); + } + + protected function applyContext(AbstractNode $node, HtmlContext $context) + { + $node->htmlContext = $context; + + if (! $node instanceof LanguageAntlersNode) { + return; + } + + foreach ($node->processedInterpolationRegions as $nodes) { + foreach ($nodes as $interpolationNode) { + if ($interpolationNode instanceof LiteralNode) { + continue; + } + + $this->applyContext($interpolationNode, clone $context); + } + } + } +} diff --git a/src/View/Instrumentation/Antlers/ElementInspector.php b/src/View/Instrumentation/Antlers/ElementInspector.php new file mode 100644 index 00000000000..54bbce6b0d5 --- /dev/null +++ b/src/View/Instrumentation/Antlers/ElementInspector.php @@ -0,0 +1,478 @@ +antlersEndInBytes($template, $index, $length); + + if ($end === false) { + return substr($template, $offset); + } + + $state = $this->stateAfterAntlers($state); + $index = $end - 1; + + continue; + } + + if ($state === 'double-quoted' || $state === 'single-quoted') { + if ($this->isClosingQuote($state, $char)) { + $state = 'before-attribute'; + } + + continue; + } + + if ($char === '>' && $state !== 'tag-name') { + return substr($template, $offset, $index + 1 - $offset); + } + + if ($state === 'tag-name') { + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } elseif ($char === '/') { + $state = 'self-closing'; + } elseif ($char === '>') { + return substr($template, $offset, $index + 1 - $offset); + } + } elseif ($state === 'before-attribute') { + if ($char === '/') { + $state = 'self-closing'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + } + } elseif ($state === 'attribute-name') { + if ($this->isHtmlSpace($char)) { + $state = 'after-attribute'; + } elseif ($char === '/') { + $state = 'self-closing'; + } elseif ($char === '=') { + $state = 'before-value'; + } + } elseif ($state === 'after-attribute') { + if ($char === '/') { + $state = 'self-closing'; + } elseif ($char === '=') { + $state = 'before-value'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + } + } elseif ($state === 'before-value') { + if ($char === '"') { + $state = 'double-quoted'; + } elseif ($char === "'") { + $state = 'single-quoted'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'unquoted-value'; + } + } elseif ($state === 'unquoted-value') { + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + } + } elseif ($state === 'self-closing') { + $state = 'before-attribute'; + $index--; + } + } + + return substr($template, $offset); + } + + /** + * @param string $template + * @param int $startByteOffset Byte offset of the element's `<`. + * @return int + */ + public function attributeInsertionOffset($template, $startByteOffset) + { + $length = strlen($template); + $index = $startByteOffset + 1; + + while ($index < $length + && ! HtmlSpec::isTagNameDelimiter($template[$index])) { + if (substr($template, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($template, $index, $length); + + if ($end === false) { + break; + } + + $index = $end; + } else { + $index++; + } + } + + return $index; + } + + /** + * @param string $markup The tag markup, starting at its `<`. + * @param int[] $offsets Byte offsets relative to the markup start. + * @return array offset => [kind, attribute name, partial run] + */ + public function classifyTagPositions($markup, array $offsets) + { + $length = strlen($markup); + $needed = array_fill_keys($offsets, true); + $classified = []; + $state = 'tag-name'; + $tagName = ''; + $attributeName = ''; + $pendingValueOwner = ''; + $index = 1; + + if (($markup[1] ?? null) === '/') { + $index = 2; + } + + while ($index < $length && count($classified) < count($needed)) { + if (isset($needed[$index]) && ! isset($classified[$index])) { + $classified[$index] = $this->classifyTagState($markup, $index, $length, $state, $tagName, $attributeName, $pendingValueOwner); + } + + if (substr($markup, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($markup, $index, $length); + + if ($end === false) { + break; + } + + if ($state === 'before-attribute' || $state === 'after-attribute') { + $attributeName = ''; + $pendingValueOwner = ''; + } + + $state = $this->stateAfterAntlers($state); + $index = $end; + + continue; + } + + $char = $markup[$index]; + + if ($state === 'double-quoted' || $state === 'single-quoted') { + if ($this->isClosingQuote($state, $char)) { + $state = 'before-attribute'; + $attributeName = ''; + } + + $index++; + + continue; + } + + switch ($state) { + case 'tag-name': + if ($this->isHtmlSpace($char) || $char === '/') { + $state = 'before-attribute'; + } elseif ($char === '>') { + $state = 'done'; + } else { + $tagName .= $char; + } + break; + case 'before-attribute': + if (! $this->isHtmlSpace($char) && $char !== '/' && $char !== '>') { + $state = 'attribute-name'; + $attributeName = $char; + } + break; + case 'attribute-name': + if ($char === '=') { + $pendingValueOwner = $attributeName; + $state = 'before-value'; + } elseif ($this->isHtmlSpace($char)) { + $pendingValueOwner = $attributeName; + $state = 'after-attribute'; + } elseif ($char === '/' || $char === '>') { + $state = 'before-attribute'; + $attributeName = ''; + } else { + $attributeName .= $char; + } + break; + case 'after-attribute': + if ($char === '=') { + $state = 'before-value'; + } elseif ($char === '/') { + $state = 'before-attribute'; + $attributeName = ''; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'attribute-name'; + $attributeName = $char; + } + break; + case 'before-value': + if ($char === '"') { + $state = 'double-quoted'; + } elseif ($char === "'") { + $state = 'single-quoted'; + } elseif (! $this->isHtmlSpace($char)) { + $state = 'unquoted-value'; + } + break; + case 'unquoted-value': + if ($this->isHtmlSpace($char)) { + $state = 'before-attribute'; + $attributeName = ''; + } + break; + } + + $index++; + } + + foreach ($needed as $offset => $unused) { + if (! isset($classified[$offset])) { + $classified[$offset] = ['tag-open', null, '']; + } + } + + return $classified; + } + + /** @return array{0: string, 1: string|null, 2: string} */ + protected function classifyTagState($markup, $index, $length, $state, $tagName, $attributeName, $pendingValueOwner) + { + switch ($state) { + case 'tag-name': + return ['element-name', null, $tagName]; + case 'attribute-name': + return ['attribute-name', $this->normalizedAttributeName($attributeName), $attributeName]; + case 'double-quoted': + case 'single-quoted': + case 'unquoted-value': + case 'before-value': + $owner = $this->attributeValueOwner($state, $attributeName, $pendingValueOwner); + + return ['attribute-value', $this->normalizedAttributeName($owner), '']; + case 'before-attribute': + case 'after-attribute': + $lookahead = $index; + + if (substr($markup, $lookahead, 2) === '{{') { + $end = $this->antlersEndInBytes($markup, $lookahead, $length); + + if ($end === false) { + $lookahead = $length; + } else { + $lookahead = $end; + } + } + + while ($lookahead < $length && $this->isHtmlSpace($markup[$lookahead])) { + $lookahead++; + } + + if (($markup[$lookahead] ?? null) === '=' && $state === 'before-attribute') { + return ['attribute-name', null, '']; + } + + return ['tag-open', null, '']; + default: + return ['tag-open', null, '']; + } + } + + protected function stateAfterAntlers($state) + { + return match ($state) { + 'before-value' => 'unquoted-value', + 'before-attribute', 'after-attribute' => 'attribute-name', + default => $state, + }; + } + + protected function normalizedAttributeName($name) + { + if ($name === '') { + return null; + } + + return strtolower($name); + } + + protected function isClosingQuote($state, $char) + { + if ($state === 'double-quoted') { + return $char === '"'; + } + + return $state === 'single-quoted' && $char === "'"; + } + + protected function attributeValueOwner($state, $attributeName, $pendingValueOwner) + { + if ($state === 'before-value' && $pendingValueOwner) { + return $pendingValueOwner; + } + + if ($attributeName) { + return $attributeName; + } + + return $pendingValueOwner; + } + + public function hasAttribute($openingTag, $attributeName) + { + if (stripos($openingTag, $attributeName) === false) { + return false; + } + + $length = strlen($openingTag); + $index = 1; + + while ($index < $length + && ! HtmlSpec::isTagNameDelimiter($openingTag[$index])) { + if (substr($openingTag, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + } else { + $index++; + } + } + + while ($index < $length) { + while ($index < $length && $this->isHtmlSpace($openingTag[$index])) { + $index++; + } + + if ($index >= $length || $openingTag[$index] === '>') { + return false; + } + + if ($openingTag[$index] === '/') { + $index++; + + continue; + } + + if (substr($openingTag, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + + continue; + } + + $nameStart = $index; + $dynamicName = false; + + if ($openingTag[$index] === '=') { + $index++; + } + + while ($index < $length + && ! HtmlSpec::isAttributeNameDelimiter($openingTag[$index])) { + if (substr($openingTag, $index, 2) === '{{') { + $dynamicName = true; + $end = $this->antlersEndInBytes($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + } else { + $index++; + } + } + + $name = substr($openingTag, $nameStart, $index - $nameStart); + + if (! $dynamicName && strcasecmp($name, $attributeName) === 0) { + return true; + } + + while ($index < $length && $this->isHtmlSpace($openingTag[$index])) { + $index++; + } + + if ($index >= $length || $openingTag[$index] !== '=') { + continue; + } + + $index++; + + while ($index < $length && $this->isHtmlSpace($openingTag[$index])) { + $index++; + } + + if ($index < $length && ($openingTag[$index] === '"' || $openingTag[$index] === "'")) { + $quote = $openingTag[$index++]; + + while ($index < $length && $openingTag[$index] !== $quote) { + if (substr($openingTag, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + } else { + $index++; + } + } + + if ($index < $length) { + $index++; + } + } else { + while ($index < $length + && ! $this->isHtmlSpace($openingTag[$index]) + && $openingTag[$index] !== '>') { + if (substr($openingTag, $index, 2) === '{{') { + $end = $this->antlersEndInBytes($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + } else { + $index++; + } + } + } + } + + return false; + } + + protected function isHtmlSpace($char) + { + return HtmlSpec::isHtmlSpace($char); + } + + /** @return int|false Offset immediately after the closing delimiter. */ + protected function antlersEndInBytes($content, $start, $length) + { + return RegionExtents::endInBytes($content, $start, $length); + } +} diff --git a/src/View/Instrumentation/Antlers/RegionExtents.php b/src/View/Instrumentation/Antlers/RegionExtents.php new file mode 100644 index 00000000000..c3075ca2315 --- /dev/null +++ b/src/View/Instrumentation/Antlers/RegionExtents.php @@ -0,0 +1,57 @@ +inspector = new ElementInspector; + $this->componentPrefixes = $componentPrefixes ?? ComponentPrefixes::DEFAULTS; + } + + /** + * @param string $template + * @param bool $collectElements Whether attribute layers are configured. + * @return array{regions: TemplateRegion[], elements: array} + */ + public function analyze($template, $collectElements = true, $commentsAtDocumentRoot = true, ?ComponentSourceMap $sourceMap = null) + { + $parser = new DocumentParser; + $parser->inheritRuntimeLineSeed(false); + $parser->parse($template); + $nodes = $parser->getNodes(); + + (new ContextScanner) + ->treatComponentsAsDynamic() + ->withComponentPrefixes($this->componentPrefixes) + ->annotate($nodes, $parser); + + $pending = []; + $characterOffsets = []; + + foreach ($nodes as $node) { + if ($this->shouldSkipNode($node)) { + continue; + } + + $context = $node->htmlContext; + + if ($context === null) { + continue; + } + + $start = $node->startPosition->offset; + $end = $node->endPosition->offset + 1; + $commentSafe = $context->isSafeForHtmlComments() + && ($commentsAtDocumentRoot || $context->elementStack !== []); + $isPaired = $node->isPaired(); + + if ($isPaired) { + $closer = $this->closingBoundaryFor($node); + $closingContext = null; + + if ($closer !== null) { + $end = $closer->endPosition->offset + 1; + $closingContext = $closer->htmlContext; + } + + $commentSafe = $commentSafe + && $closingContext !== null + && $closingContext->isSafeForHtmlComments() + && $context->sharesElementContainerWith($closingContext); + } + + $characterOffsets[] = $start; + $characterOffsets[] = $end; + + if ($this->canCollectElement($collectElements, $context)) { + $characterOffsets[] = $context->elementStartOffset; + } + + $pending[] = [$node, $context, $start, $end, $commentSafe, $isPaired]; + } + + if ($pending === []) { + return ['regions' => [], 'elements' => []]; + } + + [$byteOffsets, $sourceCharacterOffsets] = CharacterOffsets::normalizedToBytesAndCharacters($template, $characterOffsets); + $regions = []; + $elements = []; + [$mappedStarts, $mappedEnds] = $sourceMap?->positions(array_column($pending, 2), array_column($pending, 3)) ?? [[], []]; + + foreach ($pending as [$node, $context, $start, $end, $commentSafe, $isPaired]) { + $regions[] = new TemplateRegion( + Span::ENGINE_ANTLERS, + $this->regionType($isPaired), + trim($node->content), + $mappedStarts[$start]['line'] ?? $node->startPosition->line, + $mappedStarts[$start]['offset'] ?? $sourceCharacterOffsets[$byteOffsets[$start]], + $mappedEnds[$end]['offset'] ?? $sourceCharacterOffsets[$byteOffsets[$end]], + $byteOffsets[$start], + $byteOffsets[$end], + $context, + $node, + $commentSafe + ); + + if ($this->canCollectElement($collectElements, $context) + && ! isset($elements[$context->elementStartOffset])) { + $elements[$context->elementStartOffset] = $this->elementFacts( + $template, + $context, + $byteOffsets[$context->elementStartOffset] + ); + } + } + + return ['regions' => $regions, 'elements' => $elements]; + } + + protected function shouldSkipNode($node) + { + if (! $node instanceof AntlersNode || $node instanceof EscapedContentNode) { + return true; + } + + if ($node->isComment || $node->isClosingTag || $this->isComponentProxyNode($node)) { + return true; + } + + return ($node->name->name ?? null) === 'noparse'; + } + + protected function canCollectElement($collectElements, HtmlContext $context) + { + if (! $collectElements) { + return false; + } + + if ($context->elementStartOffset === null) { + return false; + } + + return $context->canInstrumentElement(); + } + + protected function regionType($isPaired) + { + if ($isPaired) { + return TemplateRegion::TYPE_PAIR; + } + + return TemplateRegion::TYPE_SINGLE; + } + + protected function isComponentProxyNode(AntlersNode $node) + { + return ComponentCompiler::isComponentProxyContent((string) $node->content); + } + + /** + * @param string $template + * @param int $byteStart + * @return array{insertAt: int, hasAttribute: callable} + */ + protected function elementFacts($template, HtmlContext $context, $byteStart) + { + $openingTag = $this->inspector->openingTagAt($template, $context->elementStartOffset, $byteStart); + $inspector = $this->inspector; + + return [ + 'insertAt' => $this->inspector->attributeInsertionOffset($template, $byteStart), + 'hasAttribute' => fn ($name) => $inspector->hasAttribute($openingTag, $name), + ]; + } + + /** @return AntlersNode|null */ + protected function closingBoundaryFor(AntlersNode $node) + { + $closer = $node->isClosedBy; + $visited = []; + + while ($closer instanceof AntlersNode && $closer->isClosedBy instanceof AntlersNode) { + $identity = spl_object_id($closer); + + if (isset($visited[$identity])) { + return null; + } + + $visited[$identity] = true; + $closer = $closer->isClosedBy; + } + + if (! $closer instanceof AntlersNode) { + return null; + } + + return $closer; + } +} diff --git a/src/View/Instrumentation/ComponentPrefixes.php b/src/View/Instrumentation/ComponentPrefixes.php new file mode 100644 index 00000000000..f27f3247467 --- /dev/null +++ b/src/View/Instrumentation/ComponentPrefixes.php @@ -0,0 +1,50 @@ +prefix($prefix); + $this->commentsEnabled = true; + + return $this; + } + + /** + * @param string $prefix + * @return $this + */ + public function prefix($prefix) + { + if (! is_string($prefix) + || ! preg_match('/^[A-Za-z0-9_-]+$/D', $prefix) + || str_contains($prefix, '--')) { + throw new \InvalidArgumentException('HTML instrumentation comment prefixes may contain letters, numbers, underscores, and single hyphens.'); + } + + $this->commentPrefix = $prefix; + $this->flushResultCache(); + + return $this; + } + + /** @return $this */ + public function withoutComments() + { + $this->commentsEnabled = false; + $this->flushResultCache(); + + return $this; + } + + /** @return $this */ + public function markersUsing(callable $factory) + { + $this->commentFactory = $factory; + $this->flushResultCache(); + + return $this; + } + + /** @return $this */ + public function filter(callable $filter) + { + $this->nodeFilter = $filter; + $this->flushResultCache(); + + return $this; + } + + /** @return $this */ + public function onSkip(callable $callback) + { + $this->skipCallback = $callback; + $this->flushResultCache(); + + return $this; + } + + /** + * @param array $patterns + * @return $this + */ + public function matching(array $patterns) + { + return $this->filter(static function ($metadata) use ($patterns) { + foreach ($patterns as $pattern) { + if (is_string($pattern) && Str::is($pattern, $metadata['expression'])) { + return true; + } + } + + return false; + }); + } + + /** + * @param string $attributeName + * @return $this + */ + public function attributes($attributeName = 'data-antlers') + { + return $this->attribute($attributeName); + } + + /** + * @param string $attributeName + * @return $this + */ + public function attribute($attributeName, ?callable $valueFactory = null) + { + if ($this->invalidInstrumentationAttributeName($attributeName)) { + throw new \InvalidArgumentException('Invalid HTML instrumentation attribute name.'); + } + + if ($valueFactory === null) { + $valueFactory = static function ($metadata) { + $json = json_encode($metadata, JSON_INVALID_UTF8_SUBSTITUTE); + + if ($json === false) { + $json = '[]'; + } + + return base64_encode($json); + }; + } + + $this->attributeLayers[$attributeName] = $valueFactory; + $this->flushResultCache(); + + return $this; + } + + protected function invalidInstrumentationAttributeName($attributeName) + { + return ! HtmlSpec::isValidName($attributeName); + } + + /** + * @param string $attributeName + * @return $this + */ + public function jsonAttribute($attributeName = 'data-antlers') + { + return $this->attribute($attributeName, static function ($metadata) { + $json = json_encode($metadata, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE); + + if ($json === false) { + return '[]'; + } + + return $json; + }); + } + + /** + * @param array $prefixes + * @return $this + */ + public function componentPrefixes(array $prefixes) + { + $this->componentPrefixes = ComponentPrefixes::merge($prefixes); + $this->flushResultCache(); + + return $this; + } + + /** + * @param array $metadata + * @return $this + */ + public function withMetadata(array $metadata) + { + $this->extraMetadata = array_merge($this->extraMetadata, $metadata); + $this->flushResultCache(); + + return $this; + } +} diff --git a/src/View/Instrumentation/Concerns/EmitsInstrumentation.php b/src/View/Instrumentation/Concerns/EmitsInstrumentation.php new file mode 100644 index 00000000000..a25c5eb87d4 --- /dev/null +++ b/src/View/Instrumentation/Concerns/EmitsInstrumentation.php @@ -0,0 +1,319 @@ +} $analysis + * @return string + */ + protected function emitInstrumentation($template, array $analysis) + { + $edits = []; + $elementMetadata = []; + $deferredSkips = []; + $nextId = 0; + + foreach ($analysis['regions'] as $region) { + $metadata = $this->regionMetadata($region); + + if ($this->nodeFilter !== null + && ! call_user_func($this->nodeFilter, $metadata, $region)) { + $this->reportSkip($metadata, $region, self::SKIP_FILTERED); + + continue; + } + + $emitsComments = $this->commentsEnabled && $region->commentSafe; + $emitsAttributes = ! empty($this->attributeLayers) && $region->context()->canInstrumentElement(); + + if (! $emitsComments && ! $emitsAttributes) { + $this->reportSkip($metadata, $region, $this->skipReasonFor($region)); + + continue; + } + + $metadata['id'] = ++$nextId; + $contributed = false; + + if ($emitsComments) { + [$start, $end] = $this->markersFor($metadata); + + if ($start !== '') { + $edits[] = ['start' => $region->byteStart, 'remove' => 0, 'content' => $start, 'type' => 'start', 'nodeStart' => $region->byteStart]; + } + + if ($end !== '') { + $edits[] = ['start' => $region->byteEnd, 'remove' => 0, 'content' => $end, 'type' => 'end', 'nodeStart' => $region->byteStart]; + } + + $contributed = $start !== '' || $end !== ''; + } + + if ($emitsAttributes) { + $elementMetadata[$region->context()->elementStartOffset][] = $metadata; + $deferredSkips[$region->context()->elementStartOffset][] = [$metadata, $region, $contributed]; + $contributed = true; + } + + if (! $contributed) { + $this->reportSkip($metadata, $region, $this->skipReasonFor($region)); + } + } + + foreach ($elementMetadata as $elementKey => $metadataList) { + $element = $analysis['elements'][$elementKey] ?? null; + + if ($element === null) { + $this->reportDeferredSkips($deferredSkips[$elementKey] ?? []); + + continue; + } + + $attributes = ''; + + foreach ($this->attributeLayers as $name => $valueFactory) { + if (($element['hasAttribute'])($name)) { + continue; + } + + $value = $valueFactory($metadataList); + + if ($value !== null) { + $attribute = ' '.$name.'="'.htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'"'; + $attributes .= $attribute; + $edits[] = [ + 'start' => $element['insertAt'], + 'remove' => 0, + 'content' => $attribute, + 'type' => 'attribute', + 'nodeStart' => $element['insertAt'], + 'attributeName' => $name, + ]; + } + } + + if ($attributes === '') { + $this->reportDeferredSkips($deferredSkips[$elementKey] ?? []); + } + } + + if ($this->collectedEdits !== null) { + $this->collectedEdits = $edits; + + return $template; + } + + return $this->spliceByteEdits($template, $edits); + } + + /** + * @param array, 1: TemplateRegion, 2: bool}> $entries + * @return void + */ + protected function reportDeferredSkips(array $entries) + { + foreach ($entries as [$metadata, $region, $alreadyContributed]) { + if (! $alreadyContributed) { + $this->reportSkip($metadata, $region, $this->skipReasonFor($region)); + } + } + } + + /** @return array */ + protected function regionMetadata(TemplateRegion $region) + { + $type = 'pair'; + + if ($region->type === TemplateRegion::TYPE_SINGLE) { + $type = 'single'; + } + + $metadata = [ + 'type' => $type, + 'engine' => $region->engine, + 'expression' => $region->expression, + 'line' => $region->line, + 'start' => $region->start, + 'end' => $region->end, + ]; + + return $this->appendContextualMetadata($metadata, $region->context()); + } + + /** + * @param array $metadata + * @return void + */ + protected function reportSkip(array $metadata, TemplateRegion $region, $reason) + { + if ($this->skipCallback !== null) { + call_user_func($this->skipCallback, $metadata, $region, $reason); + } + } + + /** @return string One of the SKIP_* constants. */ + protected function skipReasonFor(TemplateRegion $region) + { + $context = $region->context(); + + if ($context->kind === HtmlContext::KIND_COMMENT) { + return self::SKIP_INSIDE_HTML_COMMENT; + } + + if ($context->kind === HtmlContext::KIND_DOCTYPE) { + return self::SKIP_INSIDE_DOCTYPE; + } + + if ($context->kind === HtmlContext::KIND_ELEMENT_NAME + || in_array(HtmlContext::DYNAMIC_ELEMENT, $context->elementStack, true)) { + return self::SKIP_DYNAMIC_MARKUP; + } + + if ($context->isClosingTag) { + return self::SKIP_CLOSING_TAG_MARKUP; + } + + if ($this->commentsEnabled && $region->isPair() && $context->isSafeForHtmlComments()) { + return self::SKIP_CROSS_CONTAINER_PAIR; + } + + return self::SKIP_NO_STRATEGY; + } + + /** + * @param array $metadata + * @return array + */ + protected function appendContextualMetadata(array $metadata, HtmlContext $context) + { + $metadata['context'] = $context->kind; + + if ($context->elementName !== null) { + $metadata['element'] = $context->elementName; + } + + if ($context->attributeName !== null) { + $metadata['attribute'] = $context->attributeName; + } + + $view = GlobalRuntimeState::$currentExecutionFile; + + if (is_string($view) && $view !== '') { + $metadata['view'] = $this->publicViewIdentifier($view); + } + + return array_merge($metadata, $this->extraMetadata); + } + + protected function publicViewIdentifier($view) + { + $normalized = str_replace('\\', '/', $view); + + if (! preg_match('#^(?:[A-Za-z]:/|/)#', $normalized)) { + return $normalized; + } + + $base = rtrim(str_replace('\\', '/', base_path()), '/').'/'; + $comparisonView = $normalized; + $comparisonBase = $base; + + if (DIRECTORY_SEPARATOR === '\\') { + $comparisonView = strtolower($comparisonView); + $comparisonBase = strtolower($comparisonBase); + } + + if (str_starts_with($comparisonView, $comparisonBase)) { + return substr($normalized, strlen($base)); + } + + return basename($normalized); + } + + /** + * @param array $metadata + * @return array{0: string, 1: string} + */ + protected function markersFor(array $metadata) + { + if ($this->commentFactory !== null) { + $markers = call_user_func($this->commentFactory, $metadata); + + return [$markers[0] ?? '', $markers[1] ?? '']; + } + + $json = json_encode($metadata, JSON_INVALID_UTF8_SUBSTITUTE); + + if ($json === false) { + $json = '{}'; + } + + $encoded = base64_encode($json); + $id = ''; + + if (isset($metadata['id'])) { + $id = ' '.$metadata['id']; + } + + return [ + '', + '', + ]; + } + + /** + * @param string $template + * @param array $edits + * @return string + */ + protected function spliceByteEdits($template, array $edits) + { + if (empty($edits)) { + return $template; + } + + // At a shared offset, close previous regions before opening new ones and + // emit insertions before replacements consume source bytes. + $order = ['end' => 0, 'attribute' => 1, 'start' => 2, 'replace' => 3]; + + usort($edits, function ($left, $right) use ($order) { + if ($left['start'] !== $right['start']) { + return $left['start'] <=> $right['start']; + } + + $leftType = $order[$left['type']]; + $rightType = $order[$right['type']]; + + if ($leftType !== $rightType) { + return $leftType <=> $rightType; + } + + if ($left['type'] === 'end') { + // Close inner regions first. Layers on the same region close in + // reverse instrumenter order so their markers remain nested. + return ($right['nodeStart'] <=> $left['nodeStart']) + ?: (($right['instrumenter'] ?? 0) <=> ($left['instrumenter'] ?? 0)); + } + + return $left['nodeStart'] <=> $right['nodeStart']; + }); + + $result = ''; + $previous = 0; + + foreach ($edits as $edit) { + $result .= substr($template, $previous, max(0, $edit['start'] - $previous)); + $result .= $edit['content']; + $previous = max($previous, $edit['start'] + $edit['remove']); + } + + return $result.substr($template, $previous); + } +} diff --git a/src/View/Instrumentation/Concerns/MemoizesResults.php b/src/View/Instrumentation/Concerns/MemoizesResults.php new file mode 100644 index 00000000000..a0105f4dfdd --- /dev/null +++ b/src/View/Instrumentation/Concerns/MemoizesResults.php @@ -0,0 +1,71 @@ +configurationRevision++; + $this->resultCache = []; + + if ($this->cacheScope !== null) { + unset(static::$sharedResultCache[$this->cacheScope]); + $this->cacheScope = null; + } + } + + /** @return void */ + public static function flushSharedResultCache() + { + static::$sharedResultCache = []; + } + + /** + * @param string $key + * @return string|null + */ + protected function cachedResult($key) + { + if ($this->cacheScope !== null) { + return static::$sharedResultCache[$this->cacheScope][$key] ?? null; + } + + return $this->resultCache[$key] ?? null; + } + + /** + * @param string $key + * @param string $result + * @return string + */ + protected function storeResult($key, $result) + { + if ($this->cacheScope !== null) { + static::$sharedResultCache[$this->cacheScope] = $this->evict( + static::$sharedResultCache[$this->cacheScope] ?? [] + ); + + return static::$sharedResultCache[$this->cacheScope][$key] = $result; + } + + $this->resultCache = $this->evict($this->resultCache); + + return $this->resultCache[$key] = $result; + } + + /** + * @param array $cache + * @return array + */ + protected function evict(array $cache) + { + if (count($cache) < $this->resultCacheLimit) { + return $cache; + } + + return array_slice($cache, intdiv($this->resultCacheLimit, 2), null, true); + } +} diff --git a/src/View/Instrumentation/HtmlContext.php b/src/View/Instrumentation/HtmlContext.php new file mode 100644 index 00000000000..0be8ccd2210 --- /dev/null +++ b/src/View/Instrumentation/HtmlContext.php @@ -0,0 +1,223 @@ +kind = $kind; + $this->elementName = $elementName; + $this->attributeName = $attributeName; + $this->elementStack = $elementStack; + $this->elementStartOffset = $elementStartOffset; + $this->isClosingTag = $isClosingTag; + $this->requiresFormattingReconstruction = $requiresFormattingReconstruction; + } + + /** @return bool */ + public function isSafeForHtmlComments() + { + if ($this->kind !== self::KIND_ELEMENT_CONTENT) { + return false; + } + + if ($this->requiresFormattingReconstruction) { + return false; + } + + if ($this->documentHasExplicitStructure + && ($this->elementStack === [] || $this->elementStack === ['html'])) { + return false; + } + + if (in_array(self::DYNAMIC_ELEMENT, $this->elementStack, true)) { + return false; + } + + if ($this->isFosterParentingContext()) { + return false; + } + + foreach ($this->elementStack as $name) { + if (isset(HtmlSpec::COMMENT_UNSAFE_ELEMENTS[$name])) { + return false; + } + } + + return true; + } + + public function isFosterParentingContext() + { + if ($this->fosterParented) { + return true; + } + + $table = null; + + for ($index = count($this->elementStack) - 1; $index >= 0; $index--) { + if ($this->elementStack[$index] === 'table') { + $table = $index; + + break; + } + } + + if ($table === null) { + return false; + } + + $tableModes = [ + 'thead' => true, + 'tbody' => true, + 'tfoot' => true, + 'tr' => true, + 'colgroup' => true, + ]; + + for ($index = $table + 1, $count = count($this->elementStack); $index < $count; $index++) { + $name = $this->elementStack[$index]; + + if ($name === 'td' || $name === 'th' || $name === 'caption') { + return false; + } + + if (! isset($tableModes[$name])) { + return false; + } + } + + return true; + } + + public function isAttributeContext() + { + return in_array($this->kind, [self::KIND_ATTRIBUTE_NAME, self::KIND_ATTRIBUTE_VALUE], true); + } + + public function isTagContext() + { + return in_array($this->kind, [ + self::KIND_TAG_OPEN, + self::KIND_ELEMENT_NAME, + self::KIND_ATTRIBUTE_NAME, + self::KIND_ATTRIBUTE_VALUE, + ], true); + } + + public function isElementContent() + { + return $this->kind === self::KIND_ELEMENT_CONTENT; + } + + public function canInstrumentOwningElement() + { + if (! $this->hasInstrumentableElement()) { + return false; + } + + return in_array($this->kind, [ + self::KIND_TAG_OPEN, + self::KIND_ATTRIBUTE_NAME, + self::KIND_ATTRIBUTE_VALUE, + ], true); + } + + public function canInstrumentContainingElement() + { + if (! $this->hasInstrumentableElement()) { + return false; + } + + return in_array($this->kind, [self::KIND_ELEMENT_CONTENT, self::KIND_RAW_TEXT], true); + } + + protected function hasInstrumentableElement() + { + if ($this->isClosingTag) { + return false; + } + + if ($this->elementName === null || $this->elementStartOffset === null) { + return false; + } + + return ! in_array(self::DYNAMIC_ELEMENT, $this->elementStack, true); + } + + public function canInstrumentElement() + { + return $this->canInstrumentOwningElement() || $this->canInstrumentContainingElement(); + } + + public function sharesElementContainerWith(HtmlContext $other) + { + if ($this->elementStack !== $other->elementStack) { + return false; + } + + if ($this->elementStartOffset !== $other->elementStartOffset) { + return false; + } + + if ($this->requiresFormattingReconstruction || $other->requiresFormattingReconstruction) { + return false; + } + + return ! in_array(self::DYNAMIC_ELEMENT, $this->elementStack, true); + } +} diff --git a/src/View/Instrumentation/HtmlInstrumentation.php b/src/View/Instrumentation/HtmlInstrumentation.php new file mode 100644 index 00000000000..0c49d3f86d1 --- /dev/null +++ b/src/View/Instrumentation/HtmlInstrumentation.php @@ -0,0 +1,321 @@ + */ + protected $attributeLayers = []; + + /** @var array */ + protected $extraMetadata = []; + + /** @var callable|null */ + protected $nodeFilter = null; + + /** @var callable|null */ + protected $skipCallback = null; + + /** @var string[] */ + protected $componentPrefixes = ComponentPrefixes::DEFAULTS; + + /** @var array */ + protected $resultCache = []; + + /** @var array> */ + protected static $sharedResultCache = []; + + /** @var string|null */ + protected $cacheScope = null; + + /** @var int */ + protected $resultCacheLimit = 256; + + protected ?string $instanceFingerprint = null; + + protected int $configurationRevision = 0; + + protected ?array $collectedEdits = null; + + public static function instrumentTogether(string $template, array $instrumenters): string + { + if ($instrumenters === []) { + return $template; + } + + if (InstrumentationState::parsingComponentContent() || InstrumentationState::suppressesHtmlInstrumentation()) { + return $template; + } + + $invoke = fn ($instrumenter) => $instrumenter($template); + + if (count($instrumenters) === 1) { + return $invoke($instrumenters[0]); + } + + $cacheKey = 'group:'.hash('xxh128', serialize(array_map(fn ($instrumenter) => $instrumenter->cacheFingerprint(), $instrumenters))) + .$instrumenters[0]->resultCacheKey( + $template, + GlobalRuntimeState::$currentExecutionFile, + false + ); + + if (($cached = $instrumenters[0]->cachedResult($cacheKey)) !== null) { + return $cached; + } + + $edits = []; + $attributes = []; + + foreach ($instrumenters as $index => $instrumenter) { + $previous = $instrumenter->collectedEdits; + $instrumenter->collectedEdits = []; + + try { + $invoke($instrumenter); + + foreach ($instrumenter->collectedEdits as $edit) { + if (isset($edit['attributeName'])) { + $key = $edit['start'].':'.strtolower($edit['attributeName']); + + if (isset($attributes[$key])) { + continue; + } + + $attributes[$key] = true; + } + + $edit['instrumenter'] = $index; + $edits[] = $edit; + } + } finally { + $instrumenter->collectedEdits = $previous; + } + } + + return $instrumenters[0]->storeResult($cacheKey, $instrumenters[0]->spliceByteEdits($template, $edits)); + } + + public function cacheFingerprint(): string + { + if ($this->cacheScope !== null) { + return 'config:'.$this->cacheScope; + } + + $this->instanceFingerprint ??= bin2hex(random_bytes(16)); + + return $this->instanceFingerprint.':'.$this->configurationRevision; + } + + final protected function __construct() + { + } + + /** @return static */ + public static function make() + { + return new static; + } + + /** + * @param array $config + * @return self + */ + public static function fromConfig(array $config) + { + $instrumentation = static::make(); + + if (array_key_exists('comments', $config) && ! $config['comments']) { + $instrumentation->withoutComments(); + } else { + $instrumentation->comments($config['prefix'] ?? 'antlers'); + } + + foreach ((array) ($config['attributes'] ?? []) as $attributeName) { + if (is_string($attributeName) && $attributeName !== '') { + $instrumentation->attributes($attributeName); + } + } + + if (is_array($config['metadata'] ?? null)) { + $instrumentation->withMetadata($config['metadata']); + } + + if (is_array($config['componentPrefixes'] ?? null)) { + $instrumentation->componentPrefixes($config['componentPrefixes']); + } + + $instrumentation->cacheScope = static::configurationFingerprint($config); + + return $instrumentation; + } + + /** + * @param array $config + * @return string|null + */ + public static function configurationFingerprint(array $config) + { + $encoded = json_encode( + $config, + JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR + ); + + if ($encoded === false) { + return null; + } + + return md5($encoded); + } + + /** + * @param string $template + * @return string + */ + public function instrument($template) + { + return $this->instrumentAntlers($template); + } + + /** + * @param string $template + * @return string + */ + public function instrumentAntlers($template) + { + if (InstrumentationState::parsingComponentContent() || InstrumentationState::suppressesHtmlInstrumentation()) { + return $template; + } + + if ((! $this->commentsEnabled && empty($this->attributeLayers)) + || strpos($template, '{{') === false) { + return $template; + } + + $view = GlobalRuntimeState::$currentExecutionFile; + + $commentsAtDocumentRoot = ! InstrumentationState::renderingComponentView() + && ! InstrumentationState::instrumentingView(); + + $cacheKey = $this->resultCacheKey( + $template, + $view, + $commentsAtDocumentRoot + ); + $cached = null; + + if ($this->collectedEdits === null) { + $cached = $this->cachedResult($cacheKey); + } + + if ($cached !== null) { + return $cached; + } + + $analysis = (new AntlersTemplateAnalyzer($this->componentPrefixes))->analyze( + $template, + ! empty($this->attributeLayers), + $commentsAtDocumentRoot, + InstrumentationState::sourceMap() + ); + + $instrumented = $this->emitInstrumentation($template, $analysis); + + if ($this->collectedEdits !== null) { + return $instrumented; + } + + return $this->storeResult($cacheKey, $instrumented); + } + + /** + * @param string $template + * @return string + */ + public function __invoke($template) + { + return InstrumentationState::whileInstrumentingView( + fn () => $this->instrumentAntlers($template) + ); + } + + /** + * @param string $marker + * @return array|null + */ + public static function decode($marker) + { + if (str_contains($marker, ''); + } + + $decoded = base64_decode($marker, true); + + if ($decoded === false) { + return null; + } + + $metadata = json_decode($decoded, true); + + if (! is_array($metadata)) { + return null; + } + + return $metadata; + } + + protected function resultCacheKey($template, $view, $commentsAtDocumentRoot) + { + $parts = [InstrumentationState::sourceMap()?->fingerprint()]; + + $documentContext = 'nested'; + + if ($commentsAtDocumentRoot) { + $documentContext = 'root'; + } + + $viewPath = ''; + + if (is_string($view)) { + $viewPath = $view; + } + + $parts[] = $documentContext; + $parts[] = $viewPath; + $parts[] = $template; + + return hash('xxh128', serialize($parts)); + } +} diff --git a/src/View/Instrumentation/HtmlSpec.php b/src/View/Instrumentation/HtmlSpec.php new file mode 100644 index 00000000000..a8fac8475d0 --- /dev/null +++ b/src/View/Instrumentation/HtmlSpec.php @@ -0,0 +1,330 @@ +]|html[\s>\/]|head[\s>\/]|body[\s>\/])/i', $source) === 1; + } + + const VOID_ELEMENTS = [ + 'area' => true, 'base' => true, 'br' => true, 'col' => true, + 'embed' => true, 'hr' => true, 'img' => true, 'input' => true, + 'link' => true, 'meta' => true, 'source' => true, 'track' => true, + 'wbr' => true, + 'basefont' => true, 'bgsound' => true, 'frame' => true, + 'keygen' => true, 'param' => true, + ]; + + const RAW_TEXT_ELEMENTS = [ + 'script' => true, 'style' => true, 'textarea' => true, + 'title' => true, 'iframe' => true, 'noembed' => true, + 'noframes' => true, 'noscript' => true, 'xmp' => true, + ]; + + const COMMENT_UNSAFE_ELEMENTS = self::RAW_TEXT_ELEMENTS + [ + 'pre' => true, 'listing' => true, 'plaintext' => true, + ]; + + /** + * @param string $char + * @return bool + */ + public static function isHtmlSpace($char) + { + return $char === ' ' || $char === "\t" || $char === "\n" || $char === "\r" || $char === "\f"; + } + + public static function isTagNameDelimiter($char) + { + return $char === null || self::isHtmlSpace($char) || $char === '>' || $char === '/'; + } + + public static function isAttributeNameDelimiter($char) + { + return self::isTagNameDelimiter($char) || $char === '='; + } + + /** + * @param mixed $name + * @return bool + */ + public static function isValidName($name) + { + if (! is_string($name)) { + return false; + } + + if ($name === '') { + return false; + } + + if (! mb_check_encoding($name, 'UTF-8')) { + return false; + } + + return ! preg_match('/[\x00-\x20\x7F"\'<>\/=]/', $name); + } + + /** + * @param string|null $char + * @return bool + */ + public static function isAsciiAlpha($char) + { + if ($char === null) { + return false; + } + + if ($char >= 'A' && $char <= 'Z') { + return true; + } + + return $char >= 'a' && $char <= 'z'; + } + + /** + * @param mixed $value + * @param array $dynamicDelimiters + * @return string[] + */ + public static function classList($value, array $dynamicDelimiters = [['{{', '}}']]) + { + $value = (string) $value; + $classes = []; + $buffer = ''; + $length = strlen($value); + + for ($index = 0; $index < $length; $index++) { + foreach ($dynamicDelimiters as [$opening, $closing]) { + $openingLength = strlen($opening); + + if ($openingLength === 0 || substr($value, $index, $openingLength) !== $opening) { + continue; + } + + $end = strpos($value, $closing, $index + $openingLength); + + if ($end === false) { + $stop = $length; + } else { + $stop = $end + strlen($closing); + } + + $buffer .= substr($value, $index, $stop - $index); + $index = $stop - 1; + + continue 2; + } + + $char = $value[$index]; + + if (self::isHtmlSpace($char)) { + if ($buffer !== '') { + $classes[] = $buffer; + $buffer = ''; + } + + continue; + } + + $buffer .= $char; + } + + if ($buffer !== '') { + $classes[] = $buffer; + } + + return $classes; + } + + const P_CLOSING_ELEMENTS = [ + 'address' => true, 'article' => true, 'aside' => true, + 'blockquote' => true, 'center' => true, 'details' => true, + 'dialog' => true, 'dir' => true, 'div' => true, 'dl' => true, + 'fieldset' => true, 'figcaption' => true, 'figure' => true, + 'footer' => true, 'form' => true, 'h1' => true, + 'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true, + 'h6' => true, 'header' => true, 'hgroup' => true, 'hr' => true, + 'listing' => true, 'main' => true, 'menu' => true, 'nav' => true, + 'ol' => true, 'p' => true, 'plaintext' => true, 'pre' => true, + 'search' => true, 'section' => true, 'summary' => true, + 'table' => true, 'ul' => true, 'xmp' => true, 'li' => true, + 'dt' => true, 'dd' => true, + ]; + + const IMPLIED_CLOSE_TARGETS = [ + 'li' => ['li' => true], + 'dt' => ['dt' => true, 'dd' => true], + 'dd' => ['dt' => true, 'dd' => true], + 'option' => ['option' => true], + 'optgroup' => ['option' => true, 'optgroup' => true], + 'tr' => ['tr' => true], + 'td' => ['td' => true, 'th' => true], + 'th' => ['td' => true, 'th' => true], + 'thead' => ['thead' => true, 'tbody' => true, 'tfoot' => true], + 'tbody' => ['thead' => true, 'tbody' => true, 'tfoot' => true], + 'tfoot' => ['thead' => true, 'tbody' => true, 'tfoot' => true], + 'caption' => ['caption' => true], + 'colgroup' => ['colgroup' => true], + 'rb' => ['rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true], + 'rp' => ['rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true], + 'rt' => ['rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true], + 'rtc' => ['rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true], + ]; + + const SCOPE_BOUNDARIES = [ + 'applet' => true, 'caption' => true, 'html' => true, + 'table' => true, 'td' => true, 'th' => true, 'marquee' => true, + 'object' => true, 'template' => true, + ]; + + const LIST_ITEM_SCOPE_BOUNDARIES = [ + 'applet' => true, 'caption' => true, 'html' => true, + 'table' => true, 'td' => true, 'th' => true, 'marquee' => true, + 'object' => true, 'template' => true, 'ol' => true, 'ul' => true, + ]; + + const BUTTON_SCOPE_BOUNDARIES = [ + 'applet' => true, 'caption' => true, 'html' => true, + 'table' => true, 'td' => true, 'th' => true, 'marquee' => true, + 'object' => true, 'template' => true, 'button' => true, + ]; + + const TABLE_SCOPE_BOUNDARIES = [ + 'html' => true, 'table' => true, 'template' => true, + ]; + + const TABLE_SCOPED_STARTS = [ + 'thead' => true, 'tbody' => true, 'tfoot' => true, + 'caption' => true, 'colgroup' => true, 'tr' => true, 'td' => true, + 'th' => true, + ]; + + const TABLE_STRUCTURE_STARTS = [ + 'caption' => true, 'col' => true, 'colgroup' => true, + 'tbody' => true, 'td' => true, 'tfoot' => true, 'th' => true, + 'thead' => true, 'tr' => true, + ]; + + const TABLE_SECTIONS = [ + 'thead' => true, 'tbody' => true, 'tfoot' => true, + ]; + + const TABLE_STRUCTURAL_ELEMENTS = [ + 'caption' => true, 'colgroup' => true, 'thead' => true, + 'tbody' => true, 'tfoot' => true, + ]; + + const ALLOWED_IN_TABLE = [ + 'caption' => true, 'colgroup' => true, 'thead' => true, + 'tbody' => true, 'tfoot' => true, 'style' => true, 'script' => true, + 'template' => true, + ]; + + const SELECT_TABLE_ELEMENTS = [ + 'caption' => true, 'table' => true, 'tbody' => true, + 'tfoot' => true, 'thead' => true, 'tr' => true, 'td' => true, + 'th' => true, + ]; + + const HEADING_ELEMENTS = [ + 'h1' => true, 'h2' => true, 'h3' => true, 'h4' => true, + 'h5' => true, 'h6' => true, + ]; + + const SCOPED_END_TAGS = [ + 'address' => true, 'article' => true, 'aside' => true, + 'blockquote' => true, 'button' => true, 'center' => true, + 'details' => true, 'dialog' => true, 'dir' => true, 'div' => true, + 'dl' => true, 'fieldset' => true, 'figcaption' => true, + 'figure' => true, 'footer' => true, 'header' => true, + 'hgroup' => true, 'listing' => true, 'main' => true, 'menu' => true, + 'nav' => true, 'ol' => true, 'pre' => true, 'search' => true, + 'section' => true, 'summary' => true, 'ul' => true, + 'applet' => true, 'marquee' => true, 'object' => true, + ]; + + const FORMATTING_ELEMENTS = [ + 'a' => true, 'b' => true, 'big' => true, 'code' => true, + 'em' => true, 'font' => true, 'i' => true, 'nobr' => true, + 's' => true, 'small' => true, 'strike' => true, 'strong' => true, + 'tt' => true, 'u' => true, + ]; + + const FORMATTING_MARKER_ELEMENTS = [ + 'applet' => true, 'caption' => true, 'marquee' => true, + 'object' => true, 'td' => true, 'th' => true, 'template' => true, + ]; + + const FORMATTING_BREAK_STARTS = [ + 'address' => true, 'applet' => true, 'article' => true, + 'aside' => true, 'blockquote' => true, 'button' => true, + 'base' => true, 'basefont' => true, 'bgsound' => true, + 'caption' => true, 'center' => true, 'col' => true, + 'colgroup' => true, 'dd' => true, 'details' => true, 'dir' => true, + 'div' => true, 'dl' => true, 'dt' => true, 'fieldset' => true, + 'figcaption' => true, 'figure' => true, 'footer' => true, + 'form' => true, 'frame' => true, 'h1' => true, 'h2' => true, 'h3' => true, + 'h4' => true, 'h5' => true, 'h6' => true, 'header' => true, + 'hgroup' => true, 'hr' => true, 'li' => true, 'link' => true, + 'listing' => true, 'main' => true, 'marquee' => true, 'menu' => true, + 'meta' => true, 'nav' => true, 'object' => true, 'ol' => true, + 'p' => true, 'param' => true, 'plaintext' => true, + 'pre' => true, 'search' => true, 'section' => true, + 'select' => true, 'source' => true, 'table' => true, 'tbody' => true, + 'td' => true, 'tfoot' => true, 'th' => true, 'thead' => true, + 'template' => true, 'tr' => true, 'track' => true, 'ul' => true, + ]; + + const SVG_HTML_INTEGRATION_POINTS = [ + 'foreignobject' => true, 'desc' => true, 'title' => true, + ]; + + const SVG_TAG_NAMES = [ + 'altglyph' => 'altGlyph', 'altglyphdef' => 'altGlyphDef', + 'altglyphitem' => 'altGlyphItem', 'animatecolor' => 'animateColor', + 'animatemotion' => 'animateMotion', 'animatetransform' => 'animateTransform', + 'clippath' => 'clipPath', 'feblend' => 'feBlend', + 'fecolormatrix' => 'feColorMatrix', 'fecomponenttransfer' => 'feComponentTransfer', + 'fecomposite' => 'feComposite', 'feconvolvematrix' => 'feConvolveMatrix', + 'fediffuselighting' => 'feDiffuseLighting', 'fedisplacementmap' => 'feDisplacementMap', + 'fedistantlight' => 'feDistantLight', 'fedropshadow' => 'feDropShadow', + 'feflood' => 'feFlood', 'fefunca' => 'feFuncA', 'fefuncb' => 'feFuncB', + 'fefuncg' => 'feFuncG', 'fefuncr' => 'feFuncR', + 'fegaussianblur' => 'feGaussianBlur', 'feimage' => 'feImage', + 'femerge' => 'feMerge', 'femergenode' => 'feMergeNode', + 'femorphology' => 'feMorphology', 'feoffset' => 'feOffset', + 'fepointlight' => 'fePointLight', 'fespecularlighting' => 'feSpecularLighting', + 'fespotlight' => 'feSpotLight', 'fetile' => 'feTile', + 'feturbulence' => 'feTurbulence', 'foreignobject' => 'foreignObject', + 'glyphref' => 'glyphRef', 'lineargradient' => 'linearGradient', + 'radialgradient' => 'radialGradient', 'textpath' => 'textPath', + ]; + + const MATHML_TEXT_INTEGRATION_POINTS = [ + 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true, + 'mtext' => true, + ]; + + const FOREIGN_BREAKOUT_ELEMENTS = [ + 'b' => true, 'big' => true, 'blockquote' => true, 'body' => true, + 'br' => true, 'center' => true, 'code' => true, 'dd' => true, + 'div' => true, 'dl' => true, 'dt' => true, 'em' => true, + 'embed' => true, 'h1' => true, 'h2' => true, 'h3' => true, + 'h4' => true, 'h5' => true, 'h6' => true, 'head' => true, + 'hr' => true, 'i' => true, 'img' => true, 'li' => true, + 'listing' => true, 'menu' => true, 'meta' => true, 'nobr' => true, + 'ol' => true, 'p' => true, 'pre' => true, 'ruby' => true, + 's' => true, 'small' => true, 'span' => true, 'strong' => true, + 'strike' => true, 'sub' => true, 'sup' => true, 'table' => true, + 'tt' => true, 'u' => true, 'ul' => true, 'var' => true, + ]; +} diff --git a/src/View/Instrumentation/InstrumentationManager.php b/src/View/Instrumentation/InstrumentationManager.php new file mode 100644 index 00000000000..929165248b4 --- /dev/null +++ b/src/View/Instrumentation/InstrumentationManager.php @@ -0,0 +1,300 @@ + */ + protected WeakMap $runtimeConfigurations; + + /** @var array */ + protected $configured = []; + + /** @var array */ + protected $antlersPieces = []; + + /** @var callable[] */ + protected $runtimePreparsers = []; + + /** @var NodeVisitorContract[] */ + protected $runtimeVisitors = []; + + public function __construct() + { + $this->runtimeConfigurations = new WeakMap; + } + + public function preparse(callable $preparser): void + { + if (! in_array($preparser, $this->runtimePreparsers, true)) { + $this->runtimePreparsers[] = $preparser; + } + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->preparse($preparser)); + } + + public function addVisitor(NodeVisitorContract $visitor): void + { + if (! in_array($visitor, $this->runtimeVisitors, true)) { + $this->runtimeVisitors[] = $visitor; + } + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->addVisitor($visitor)); + } + + /** + * @param object|string $piece + * @param string[]|null $engines + * @return $this + */ + public function register($piece, ?array $engines = null) + { + $engines = $this->normalizeEngines($engines); + $abstract = null; + + if (is_string($piece)) { + $abstract = $piece; + $piece = $this->configured[$abstract] ?? app($abstract); + } + + if ($piece instanceof HtmlInstrumentation) { + $this->registerHtmlInstrumentation($piece, $engines); + } elseif ($piece instanceof RuntimeTracerContract || $piece instanceof TracerContract) { + $this->registerTracer($piece, $engines); + } else { + throw new InvalidArgumentException( + 'Instrumentation pieces must be an HtmlInstrumentation instance, a TracerContract, or a RuntimeTracerContract.' + ); + } + + if ($abstract !== null) { + $this->configured[$abstract] ??= $piece; + } + + return $this; + } + + /** + * @param array $config + * @return HtmlInstrumentation + */ + public function configuredHtmlInstrumentation(array $config) + { + $key = $this->configurationKey($config); + + if ($key === null) { + return HtmlInstrumentation::fromConfig($config); + } + + return $this->configured[$key] ??= HtmlInstrumentation::fromConfig($config); + } + + /** + * @param array $config + * @return string|null + */ + protected function configurationKey(array $config) + { + $fingerprint = HtmlInstrumentation::configurationFingerprint($config); + + return $fingerprint === null ? null : 'html:'.$fingerprint; + } + + /** + * @param object|string $piece + * @param string[]|null $engines + * @return $this + */ + public function forget($piece, ?array $engines = null) + { + $engines = $this->normalizeEngines($engines); + + if (is_string($piece)) { + $resolved = $this->configured[$piece] ?? null; + + if ($resolved === null) { + return $this; + } + + if ($engines === null) { + unset($this->configured[$piece]); + } + + $piece = $resolved; + } + + $wantsAntlers = $engines === null || in_array(static::ENGINE_ANTLERS, $engines, true); + + if ($wantsAntlers) { + $this->antlersPieces = $this->without($this->antlersPieces, $piece); + + $this->removeFromAntlers($piece); + } + + return $this; + } + + /** @return $this */ + public function flush() + { + $antlers = $this->antlersPieces; + + $this->configured = []; + $this->antlersPieces = []; + + foreach ($antlers as $piece) { + $this->removeFromAntlers($piece); + } + + return $this; + } + + protected function removeFromAntlers($piece): void + { + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($piece) { + if ($piece instanceof HtmlInstrumentation) { + $config->removePreparser($piece); + + return; + } + + $config->traceManager?->removeTracer($piece); + }); + } + + /** @return void */ + protected function rememberAntlersPiece($piece) + { + if (! in_array($piece, $this->antlersPieces, true)) { + $this->antlersPieces[] = $piece; + } + } + + /** + * @template T + * + * @param T[] $items + * @return T[] + */ + protected function without(array $items, $needle) + { + return array_values(array_filter($items, fn ($item) => $item !== $needle)); + } + + /** @return void */ + public function applyTo(RuntimeConfiguration $runtimeConfiguration) + { + // Removing our tracers must preserve tracing enabled by the parser itself. + $this->runtimeConfigurations[$runtimeConfiguration] ??= $runtimeConfiguration->isTracingEnabled; + + $config = config('statamic.antlers.instrumentation', []); + + if (is_array($config) && ($config['enabled'] ?? false)) { + $markerConfig = array_intersect_key($config, array_flip(['comments', 'prefix', 'attributes'])); + $runtimeConfiguration->preparse($this->configuredHtmlInstrumentation($markerConfig)); + } + + foreach ($this->runtimePreparsers as $preparser) { + $runtimeConfiguration->preparse($preparser); + } + + foreach ($this->runtimeVisitors as $visitor) { + $runtimeConfiguration->addVisitor($visitor); + } + + foreach ($this->antlersPieces as $piece) { + if ($piece instanceof HtmlInstrumentation) { + $runtimeConfiguration->preparse($piece); + + continue; + } + + $runtimeConfiguration->enableTracing()->registerTracer($piece); + } + } + + /** + * @param RuntimeTracerContract|TracerContract $tracer + * @param string[]|null $engines + * @return $this + */ + protected function registerTracer($tracer, ?array $engines) + { + $engines ??= [static::ENGINE_ANTLERS]; + + if (in_array(static::ENGINE_ANTLERS, $engines, true)) { + $this->rememberAntlersPiece($tracer); + + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($tracer) { + $config->enableTracing()->registerTracer($tracer); + }); + } + + return $this; + } + + /** + * @param string[]|null $engines + * @return $this + */ + protected function registerHtmlInstrumentation(HtmlInstrumentation $instrumentation, ?array $engines) + { + $wantsAntlers = $engines === null || in_array(static::ENGINE_ANTLERS, $engines, true); + + if ($wantsAntlers) { + $this->rememberAntlersPiece($instrumentation); + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->preparse($instrumentation)); + } + + return $this; + } + + /** @return void */ + protected function applyToAntlers(callable $apply) + { + foreach ($this->runtimeConfigurations as $configuration => $tracingEnabled) { + $apply($configuration); + $configuration->isTracingEnabled = $tracingEnabled || $configuration->hasRegisteredTracers(); + } + } + + /** + * @param string[]|null $engines + * @return string[]|null + */ + protected function normalizeEngines(?array $engines) + { + if ($engines === null) { + return null; + } + + foreach ($engines as $engine) { + if (! is_string($engine)) { + throw new InvalidArgumentException('Instrumentation engine names must be strings.'); + } + } + + $engines = array_values(array_unique(array_map('strtolower', $engines))); + + if ($engines === []) { + throw new InvalidArgumentException('At least one engine must be provided.'); + } + + foreach ($engines as $engine) { + if (! in_array($engine, [static::ENGINE_ANTLERS], true)) { + throw new InvalidArgumentException("Unknown instrumentation engine [{$engine}]."); + } + } + + return $engines; + } +} diff --git a/src/View/Instrumentation/InstrumentationState.php b/src/View/Instrumentation/InstrumentationState.php new file mode 100644 index 00000000000..ed85adf1039 --- /dev/null +++ b/src/View/Instrumentation/InstrumentationState.php @@ -0,0 +1,102 @@ + 0; + } + + public static function renderingComponentView() + { + return self::$componentViewDepth > 0; + } + + public static function instrumentingView() + { + return self::$viewInstrumentationDepth > 0; + } + + public static function suppressesHtmlInstrumentation(): bool + { + return self::$suppressHtmlInstrumentation; + } + + public static function whileRenderingInContext(?HtmlContext $context, callable $callback) + { + $previous = self::$suppressHtmlInstrumentation; + self::$suppressHtmlInstrumentation = $previous + || ($context !== null && ! $context->isSafeForHtmlComments()); + + try { + return $callback(); + } finally { + self::$suppressHtmlInstrumentation = $previous; + } + } + + public static function whileParsingComponentContent(callable $callback) + { + self::$componentContentDepth++; + + try { + return $callback(); + } finally { + self::$componentContentDepth--; + } + } + + public static function whileRenderingComponentView(callable $callback) + { + self::$componentViewDepth++; + + try { + return $callback(); + } finally { + self::$componentViewDepth--; + } + } + + public static function whileInstrumentingView(callable $callback) + { + self::$viewInstrumentationDepth++; + + try { + return $callback(); + } finally { + self::$viewInstrumentationDepth--; + } + } +} diff --git a/src/View/Instrumentation/Span.php b/src/View/Instrumentation/Span.php new file mode 100644 index 00000000000..b346a87fb74 --- /dev/null +++ b/src/View/Instrumentation/Span.php @@ -0,0 +1,84 @@ +|null */ + private ?array $capturedScope = null; + + private bool $scopeCaptured = false; + + private function __construct( + public readonly string $engine, + public readonly string $kind, + public readonly string $expression, + public readonly ?string $view, + public readonly ?int $line, + private readonly mixed $raw, + private readonly array $meta = [], + ) { + } + + public static function antlersNode( + AbstractNode $node, + ?NodeProcessor $processor = null, + int $processorDepth = 0 + ): self { + $view = GlobalRuntimeState::$currentExecutionFile; + + if (! is_string($view) || $view === '') { + $view = null; + } + + return new self( + self::ENGINE_ANTLERS, + self::KIND_NODE, + trim((string) $node->content), + $view, + $node->startPosition->line ?? null, + $node, + ['processor' => $processor, 'processor_depth' => $processorDepth], + ); + } + + public function raw(): mixed + { + return $this->raw; + } + + public function meta(?string $key = null): mixed + { + if ($key === null) { + return $this->meta; + } + + return $this->meta[$key] ?? null; + } + + public function scope(): ?array + { + if ($this->scopeCaptured) { + return $this->capturedScope; + } + + $this->scopeCaptured = true; + + $processor = $this->meta['processor'] ?? null; + + if ($processor instanceof NodeProcessor) { + return $this->capturedScope = $processor->getActiveData(); + } + + return null; + } +} diff --git a/src/View/Instrumentation/TemplateRegion.php b/src/View/Instrumentation/TemplateRegion.php new file mode 100644 index 00000000000..4b891ecdfb6 --- /dev/null +++ b/src/View/Instrumentation/TemplateRegion.php @@ -0,0 +1,41 @@ +context; + } + + public function raw(): mixed + { + return $this->raw; + } + + public function isPair(): bool + { + return $this->type !== self::TYPE_SINGLE; + } +} diff --git a/src/View/Instrumentation/TracerContract.php b/src/View/Instrumentation/TracerContract.php new file mode 100644 index 00000000000..4f98df9a31e --- /dev/null +++ b/src/View/Instrumentation/TracerContract.php @@ -0,0 +1,13 @@ + Date: Sun, 11 Oct 2026 13:55:22 -0500 Subject: [PATCH 02/19] Config adjustments --- config/antlers.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/config/antlers.php b/config/antlers.php index 7176b9c1830..e2a2f084e7a 100644 --- a/config/antlers.php +++ b/config/antlers.php @@ -15,15 +15,39 @@ 'debugbar' => env('STATAMIC_ANTLERS_DEBUGBAR', true), + /* + |-------------------------------------------------------------------------- + | Tracing + |-------------------------------------------------------------------------- + | + | When tracing is enabled, every tracer listed here is resolved from the + | container and notified as every Antlers node is entered and exited. + | + */ + 'tracing' => env('STATAMIC_ANTLERS_TRACING', false), 'tracers' => [], + /* + |-------------------------------------------------------------------------- + | HTML Instrumentation + |-------------------------------------------------------------------------- + | + | When enabled, rendered Antlers regions are wrapped in HTML comments and + | attributes that point back to the templates they came from. Analysis + | is cached by template content; set "cache" to false to skip that. + | + */ + 'instrumentation' => [ 'enabled' => env('STATAMIC_ANTLERS_INSTRUMENTATION', false), + 'cache' => env('STATAMIC_ANTLERS_INSTRUMENTATION_CACHE', true), 'comments' => true, 'prefix' => 'antlers', 'attributes' => [], + 'metadata' => [], + 'componentPrefixes' => [], ], /* From a4e0cef067e1aaa29940265249a6bff786ffdba8 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sun, 11 Oct 2026 16:28:13 -0500 Subject: [PATCH 03/19] Refine the Antlers HTML analyzer --- .../Language/Analyzers/Html/AntlersNode.php | 21 +- .../Antlers/Language/Analyzers/Html/Arena.php | 100 +++-- .../Concerns/AdoptsFormattingElements.php | 203 +++++---- .../Html/Concerns/AnchorsSourceNodes.php | 56 ++- .../Html/Concerns/BuildsDocumentGraph.php | 362 ++++++++++----- .../Html/Concerns/ClosesImpliedElements.php | 107 +++-- .../Html/Concerns/ParsesForeignContent.php | 32 +- .../Html/Concerns/RecoversTables.php | 118 ++--- .../Analyzers/Html/Concerns/ScansMarkup.php | 322 +++++++++----- .../Language/Analyzers/Html/Container.php | 97 ++-- .../Language/Analyzers/Html/Document.php | 414 ++++++++++++------ .../Language/Analyzers/Html/Element.php | 245 +++++++---- .../Antlers/Language/Analyzers/Html/Node.php | 129 +----- .../Language/Analyzers/Html/NodeSequence.php | 56 +-- .../Language/Analyzers/Html/OpaqueNode.php | 33 +- .../Language/Analyzers/Html/Region.php | 58 +-- .../Language/Analyzers/Html/SourceAnchor.php | 51 +-- .../Antlers/Language/Analyzers/Html/Text.php | 17 +- 18 files changed, 1365 insertions(+), 1056 deletions(-) diff --git a/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php b/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php index ff4844019be..f382e67cbb3 100644 --- a/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php +++ b/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php @@ -7,11 +7,11 @@ /** @internal */ class AntlersNode extends Node { - protected $node; + protected LanguageAntlersNode $node; - protected $source; + protected string $source; - public function __construct(Document $document, LanguageAntlersNode $node, $source) + public function __construct(Document $document, LanguageAntlersNode $node, string $source) { parent::__construct($document); @@ -19,7 +19,7 @@ public function __construct(Document $document, LanguageAntlersNode $node, $sour $this->source = $source; } - public static function fromArena(Document $document, $id) + public static function fromArena(Document $document, int $id): self { $arena = $document->arena(); $node = new self($document, $arena->antlersNode[$id], $arena->value[$id]); @@ -28,12 +28,12 @@ public static function fromArena(Document $document, $id) return $node; } - public function antlersNode() + public function antlersNode(): LanguageAntlersNode { return $this->node; } - public function source($source = null) + public function source(?string $source = null): string|static { if (func_num_args() === 0) { return $this->source; @@ -53,14 +53,19 @@ public function source($source = null) if ($this->arenaId !== null) { $this->document->arena()->value[$this->arenaId] = $this->source; + unset($this->document->arena()->sourceStart[$this->arenaId]); } $this->touchDocument(); return $this; } - protected function render() + protected function render(): string { - return $this->source; + if ($this->arenaId === null) { + return $this->source; + } + + return $this->document->valueMarkup($this->arenaId); } } diff --git a/src/View/Antlers/Language/Analyzers/Html/Arena.php b/src/View/Antlers/Language/Analyzers/Html/Arena.php index e96d88ec161..a1ca02ff45c 100644 --- a/src/View/Antlers/Language/Analyzers/Html/Arena.php +++ b/src/View/Antlers/Language/Analyzers/Html/Arena.php @@ -30,51 +30,57 @@ class Arena const MATHML = 64; /** @var int[] */ - public $kinds = [self::DOCUMENT]; + public array $kinds = [self::DOCUMENT]; /** @var array */ - public $parents = [null]; + public array $parents = [null]; /** @var array */ - public $firstChildren = [null]; + public array $firstChildren = [null]; /** @var array */ - public $lastChildren = [null]; + public array $lastChildren = [null]; /** @var array */ - public $previousSiblings = [null]; + public array $previousSiblings = [null]; /** @var array */ - public $nextSiblings = [null]; + public array $nextSiblings = [null]; /** @var array */ - public $name = [0 => null]; + public array $name = [0 => null]; /** @var string[] */ - public $value = ['']; + public array $value = ['']; /** @var array */ - public $closing = [0 => '']; + public array $closing = [0 => '']; /** @var array */ - public $opaqueType = []; + public array $opaqueType = []; /** @var array */ - public $antlersNode = []; + public array $antlersNode = []; /** @var array */ - public $embedded = []; + public array $embedded = []; /** @var array */ - public $embeddedOwner = []; + public array $embeddedOwner = []; /** @var array target id => anchor id */ - public $sourceAnchor = []; + public array $sourceAnchor = []; /** @var array anchor id => target id */ - public $anchorTarget = []; + public array $anchorTarget = []; - public function allocate($kind) + /** @var array node id => normalized byte offset of its unedited value */ + public array $sourceStart = []; + + /** @var array element id => normalized byte offset of its unedited closing markup */ + public array $closingStart = []; + + public function allocate(int $kind): int { $id = count($this->kinds); $this->kinds[] = $kind; @@ -88,7 +94,7 @@ public function allocate($kind) return $id; } - public function element($name, $opening, $selfClosing = false, $namespace = 'html', $synthetic = false) + public function element(?string $name, string $opening, bool $selfClosing = false, string $namespace = 'html', bool $synthetic = false): int { $flags = self::ELEMENT; @@ -108,21 +114,15 @@ public function element($name, $opening, $selfClosing = false, $namespace = 'htm $flags |= self::MATHML; } - $id = count($this->kinds); - $this->kinds[] = $flags; - $this->parents[] = null; - $this->firstChildren[] = null; - $this->lastChildren[] = null; - $this->previousSiblings[] = null; - $this->nextSiblings[] = null; + $id = $this->allocate($flags); $this->name[$id] = $name; - $this->value[] = $opening; + $this->value[$id] = $opening; $this->closing[$id] = ''; return $id; } - public function text($value) + public function text(string $value, ?int $sourceStart = null): int { $id = count($this->kinds); $this->kinds[] = self::TEXT; @@ -133,10 +133,14 @@ public function text($value) $this->nextSiblings[] = null; $this->value[] = $value; + if ($sourceStart !== null) { + $this->sourceStart[$id] = $sourceStart; + } + return $id; } - public function antlers(LanguageAntlersNode $node, $source) + public function antlers(LanguageAntlersNode $node, string $source): int { $id = $this->allocate(self::ANTLERS); $this->antlersNode[$id] = $node; @@ -145,7 +149,7 @@ public function antlers(LanguageAntlersNode $node, $source) return $id; } - public function opaque($source, $type) + public function opaque(string $source, string $type): int { $id = $this->allocate(self::OPAQUE); $this->value[$id] = $source; @@ -154,7 +158,7 @@ public function opaque($source, $type) return $id; } - public function anchor($target) + public function anchor(int $target): int { $id = $this->allocate(self::SOURCE_ANCHOR); $this->anchorTarget[$id] = $target; @@ -163,42 +167,42 @@ public function anchor($target) return $id; } - public function kind($id) + public function kind(int $id): int { return $this->kinds[$id]; } - public function parent($id) + public function parent(int $id): ?int { return $this->parents[$id]; } - public function setParent($id, $parent) + public function setParent(int $id, ?int $parent): void { $this->parents[$id] = $parent; } - public function firstChild($id) + public function firstChild(int $id): ?int { return $this->firstChildren[$id]; } - public function lastChild($id) + public function lastChild(int $id): ?int { return $this->lastChildren[$id]; } - public function previous($id) + public function previous(int $id): ?int { return $this->previousSiblings[$id]; } - public function next($id) + public function next(int $id): ?int { return $this->nextSiblings[$id]; } - public function anchorExisting($target) + public function anchorExisting(int $target): int { $parent = $this->parent($target); @@ -232,7 +236,7 @@ public function anchorExisting($target) return $anchor; } - public function append($parent, $node) + public function append(int $parent, int $node): int { $last = $this->lastChildren[$parent]; $this->parents[$node] = $parent; @@ -250,7 +254,7 @@ public function append($parent, $node) return $node; } - public function insertBefore($reference, $node) + public function insertBefore(int $reference, int $node): int { $parent = $this->parent($reference); $previous = $this->previous($reference); @@ -268,7 +272,7 @@ public function insertBefore($reference, $node) return $node; } - public function insertAfter($reference, $node) + public function insertAfter(int $reference, int $node): int { $parent = $this->parent($reference); $next = $this->next($reference); @@ -286,7 +290,7 @@ public function insertAfter($reference, $node) return $node; } - public function detach($node) + public function detach(int $node): int { $parent = $this->parent($node); @@ -316,7 +320,7 @@ public function detach($node) return $node; } - public function children($parent) + public function children(int $parent): array { $children = []; $seen = []; @@ -333,12 +337,12 @@ public function children($parent) return $children; } - public function hasChild($parent, $node) + public function hasChild(int $parent, int $node): bool { return $this->parent($node) === $parent && ! isset($this->embeddedOwner[$node]); } - public function namespace($id) + public function namespace(int $id): string { $type = $this->kind($id); @@ -353,22 +357,22 @@ public function namespace($id) return 'html'; } - protected function setFirstChild($id, $child) + protected function setFirstChild(int $id, ?int $child): void { $this->firstChildren[$id] = $child; } - protected function setLastChild($id, $child) + protected function setLastChild(int $id, ?int $child): void { $this->lastChildren[$id] = $child; } - protected function setPrevious($id, $previous) + protected function setPrevious(int $id, ?int $previous): void { $this->previousSiblings[$id] = $previous; } - protected function setNext($id, $next) + protected function setNext(int $id, ?int $next): void { $this->nextSiblings[$id] = $next; } diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php index fefaea3627d..a91aa354ae2 100644 --- a/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php @@ -2,6 +2,8 @@ namespace Statamic\View\Antlers\Language\Analyzers\Html\Concerns; +use Statamic\View\Antlers\Language\Analyzers\Html\Arena; + /** @internal */ trait AdoptsFormattingElements { @@ -9,7 +11,7 @@ trait AdoptsFormattingElements private const MAX_FORMATTING_INNER_RECONSTRUCTIONS = 3; - protected function closeFormattingElement($name, $closingMarkup = null, $start = null, $end = null) + protected function closeFormattingElement(string $name, ?string $closingMarkup = null, ?int $start = null, ?int $end = null): bool { $activeIndex = $this->activeFormattingIndex($name); @@ -18,10 +20,10 @@ protected function closeFormattingElement($name, $closingMarkup = null, $start = } $formatting = $this->activeFormatting[$activeIndex]; - $match = array_search($formatting, $this->stack, true); + $match = $this->openStackIndex($formatting); if ($match === false) { - array_splice($this->activeFormatting, $activeIndex, 1); + $this->removeActiveFormattingAt($activeIndex); if ($closingMarkup !== null) { $this->appendOpaqueMarkup($start, $end, $closingMarkup); @@ -43,20 +45,17 @@ protected function closeFormattingElement($name, $closingMarkup = null, $start = } if ($closingMarkup !== null) { - $this->attachEmbeddedRegions($formatting, $start, $end); - $this->setElementClosingMarkup($formatting, $closingMarkup); + $this->attachEmbeddedRegions($formatting, $start, $end, true); + $this->setElementClosingMarkup($formatting, $closingMarkup, $start); } $this->sliceOpenStack($match); - - if ($activeIndex !== null) { - array_splice($this->activeFormatting, $activeIndex, 1); - } + $this->removeActiveFormattingAt($activeIndex); return true; } - protected function adoptFormattingAroundBlocks($match, $formatting, $closingMarkup, $start, $end) + protected function adoptFormattingAroundBlocks(int $match, int $formatting, ?string $closingMarkup, ?int $start, ?int $end): bool { $transformed = false; $closingOwner = null; @@ -69,10 +68,10 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark } $formatting = $this->activeFormatting[$activeIndex]; - $match = array_search($formatting, $this->stack, true); + $match = $this->openStackIndex($formatting); if ($match === false) { - array_splice($this->activeFormatting, $activeIndex, 1); + $this->removeActiveFormattingAt($activeIndex); break; } @@ -85,7 +84,7 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $closingOwner = $formatting; $this->sliceOpenStack($match); - array_splice($this->activeFormatting, $activeIndex, 1); + $this->removeActiveFormattingAt($activeIndex); break; } @@ -97,25 +96,22 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $commonAncestor = $this->stack[$match - 1]; } - // The bookmark is the replacement's position in active formatting; - // it must follow removals and reconstructed ancestors below. $bookmark = $activeIndex; $lastNode = $furthest; $inner = 0; - // Rebuild the ancestors between the block and the formatting element. for ($index = $furthestIndex - 1; $index > $match; $index--) { $inner++; $node = $this->stack[$index]; - $nodeActiveIndex = array_search($node, $this->activeFormatting, true); + $nodeActiveIndex = $this->activeFormattingPosition($node); if ($inner > self::MAX_FORMATTING_INNER_RECONSTRUCTIONS && $nodeActiveIndex !== false) { - array_splice($this->activeFormatting, $nodeActiveIndex, 1); + $this->removeActiveFormattingAt($nodeActiveIndex); $nodeActiveIndex = false; } if ($nodeActiveIndex === false) { - array_splice($this->stack, $index, 1); + $this->removeOpenElementAt($index); $furthestIndex--; continue; @@ -123,7 +119,7 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $copy = $this->reconstructedCopy($node); $this->activeFormatting[$nodeActiveIndex] = $copy; - $this->stack[$index] = $copy; + $this->replaceOpenElementAt($index, $copy); if ($lastNode === $furthest) { $bookmark = $nodeActiveIndex + 1; @@ -134,7 +130,6 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $lastNode = $copy; } - // Move the rebuilt subtree, retaining its authored rendering position. $this->anchorArenaNodeForMove($lastNode); if ($this->arena->parent($formatting) === $commonAncestor) { @@ -156,11 +151,17 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $this->arena->append($commonAncestor, $lastNode); } - // Wrap the block's children and replace the old formatting entry in - // both lists. Keep these mutations ordered as their indices change. $newFormatting = $this->reconstructedCopy($formatting); - foreach ($this->arena->children($furthest) as $child) { + $moving = []; + + for ($child = $this->arena->firstChild($furthest); $child !== null; $child = $this->arena->next($child)) { + if (($this->arena->kinds[$child] & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + $moving[] = $child; + } + } + + foreach ($moving as $child) { $this->anchorArenaNodeForMove($child); $this->arena->append($newFormatting, $child); } @@ -168,32 +169,36 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark $this->arena->append($furthest, $newFormatting); $closingOwner = $newFormatting; - $formattingActiveIndex = array_search($formatting, $this->activeFormatting, true); + $formattingActiveIndex = $this->activeFormattingPosition($formatting); if ($formattingActiveIndex !== false) { - array_splice($this->activeFormatting, $formattingActiveIndex, 1); + $this->removeActiveFormattingAt($formattingActiveIndex); if ($formattingActiveIndex < $bookmark) { $bookmark--; } } - array_splice($this->activeFormatting, $bookmark, 0, [$newFormatting]); - $formattingStackIndex = array_search($formatting, $this->stack, true); + $this->insertActiveFormattingAt($bookmark, $newFormatting); + $formattingStackIndex = $this->openStackIndex($formatting); if ($formattingStackIndex !== false) { - array_splice($this->stack, $formattingStackIndex, 1); + $this->removeOpenElementAt($formattingStackIndex); } - $furthestStackIndex = array_search($furthest, $this->stack, true); + $furthestStackIndex = $this->openStackIndex($furthest); if ($furthestStackIndex === false) { - $this->stack[] = $newFormatting; + $this->pushOpenElement($newFormatting); } else { - array_splice($this->stack, $furthestStackIndex + 1, 0, [$newFormatting]); + $this->insertOpenElementAt($furthestStackIndex + 1, $newFormatting); } } + if ($transformed) { + $this->insertionChains = []; + } + if (! $transformed || $closingOwner === null) { return false; } @@ -202,21 +207,21 @@ protected function adoptFormattingAroundBlocks($match, $formatting, $closingMark if ($outer === self::MAX_FORMATTING_ADOPTION_PASSES) { $this->appendOpaqueMarkup($start, $end, $closingMarkup); } else { - $this->setElementClosingMarkup($closingOwner, $closingMarkup); - $this->attachEmbeddedRegions($closingOwner, $start, $end); + $this->setElementClosingMarkup($closingOwner, $closingMarkup, $start); + $this->attachEmbeddedRegions($closingOwner, $start, $end, true); } } return true; } - protected function reconstructActiveFormatting() + protected function reconstructActiveFormatting(): void { $count = count($this->activeFormatting); if ($count === 0 || $this->activeFormatting[$count - 1] === null - || in_array($this->activeFormatting[$count - 1], $this->stack, true)) { + || $this->openStackIndex($this->activeFormatting[$count - 1]) !== false) { return; } @@ -224,7 +229,7 @@ protected function reconstructActiveFormatting() while ($index > 0 && $this->activeFormatting[$index - 1] !== null - && ! in_array($this->activeFormatting[$index - 1], $this->stack, true)) { + && $this->openStackIndex($this->activeFormatting[$index - 1]) === false) { $index--; } @@ -238,12 +243,12 @@ protected function reconstructActiveFormatting() $this->arena->append($container, $copy); } - $this->stack[] = $copy; + $this->pushOpenElement($copy); $this->activeFormatting[$index] = $copy; } } - protected function activeFormattingIndex($name) + protected function activeFormattingIndex(string $name): ?int { for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { if ($this->activeFormatting[$index] === null) { @@ -258,7 +263,7 @@ protected function activeFormattingIndex($name) return null; } - protected function stackIndexIsInScope($targetIndex, array $boundaries) + protected function stackIndexIsInScope(int $targetIndex, array $boundaries): bool { for ($index = count($this->stack) - 1; $index >= 0; $index--) { if ($index === $targetIndex) { @@ -275,7 +280,7 @@ protected function stackIndexIsInScope($targetIndex, array $boundaries) continue; } - if (isset($boundaries[strtolower((string) $this->arena->name[$element])])) { + if (isset($boundaries[$this->arena->name[$element] ?? ''])) { return false; } } @@ -283,11 +288,11 @@ protected function stackIndexIsInScope($targetIndex, array $boundaries) return false; } - protected function furthestFormattingBoundaryIndex($formattingIndex) + protected function furthestFormattingBoundaryIndex(int $formattingIndex): ?int { for ($index = $formattingIndex + 1, $count = count($this->stack); $index < $count; $index++) { $candidate = $this->stack[$index]; - $name = strtolower((string) $this->arena->name[$candidate]); + $name = $this->arena->name[$candidate] ?? ''; if ($this->isFormattingBoundary($candidate, $name)) { return $index; @@ -297,7 +302,7 @@ protected function furthestFormattingBoundaryIndex($formattingIndex) return null; } - protected function isFormattingBoundary($candidate, $name) + protected function isFormattingBoundary(int $candidate, string $name): bool { if ($this->isForeignElement($candidate)) { return $this->isForeignScopeBoundary($candidate); @@ -306,75 +311,123 @@ protected function isFormattingBoundary($candidate, $name) return isset(self::$formattingBreakStarts[$name]); } - protected function pushActiveFormattingElement($element) + protected function pushActiveFormattingElement(int $element): void { - $equivalent = []; + $signature = $this->formattingSignature($element); - for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { - $candidate = $this->activeFormatting[$index]; + if (($this->activeSignatureCounts[$signature] ?? 0) >= 3) { + $equivalent = []; - if ($candidate === null) { - break; - } + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + $candidate = $this->activeFormatting[$index]; + + if ($candidate === null) { + break; + } + + if ($this->formattingSignature($candidate) === $signature) { + $equivalent[] = $index; - if ($this->formattingElementsAreEquivalent($candidate, $element)) { - $equivalent[] = $index; + if (count($equivalent) === 3) { + break; + } + } } - } - if (count($equivalent) >= 3) { - array_splice($this->activeFormatting, $equivalent[count($equivalent) - 1], 1); + if (count($equivalent) >= 3) { + $this->removeActiveFormattingAt($equivalent[count($equivalent) - 1]); + } } $this->activeFormatting[] = $element; + $this->countActiveFormatting($element, 1); } - protected function formattingElementsAreEquivalent($left, $right) + protected function pushActiveFormattingMarker(): void { - if ($this->arena->name[$left] !== $this->arena->name[$right] - || $this->arena->namespace($left) !== $this->arena->namespace($right)) { - return false; - } + $this->activeFormatting[] = null; + $this->activeSignatureCountStack[] = $this->activeSignatureCounts; + $this->activeSignatureCounts = []; + } - $leftMarkup = $this->arena->value[$left]; - $rightMarkup = $this->arena->value[$right]; + protected function removeActiveFormattingAt(int $index): void + { + [$element] = array_splice($this->activeFormatting, $index, 1); - if (strpos($leftMarkup, '{{') !== false || strpos($rightMarkup, '{{') !== false) { - return $leftMarkup === $rightMarkup; + if ($element !== null) { + $this->countActiveFormatting($element, -1); } + } + + protected function insertActiveFormattingAt(int $index, int $element): void + { + array_splice($this->activeFormatting, $index, 0, [$element]); + $this->countActiveFormatting($element, 1); + } - $leftAttributes = $this->handle($left)->attributes(); - $rightAttributes = $this->handle($right)->attributes(); - ksort($leftAttributes); - ksort($rightAttributes); + protected function countActiveFormatting(int $element, int $delta): void + { + $signature = $this->formattingSignature($element); + $count = ($this->activeSignatureCounts[$signature] ?? 0) + $delta; - return $leftAttributes === $rightAttributes; + if ($count > 0) { + $this->activeSignatureCounts[$signature] = $count; + } else { + unset($this->activeSignatureCounts[$signature]); + } } - protected function sliceOpenStack($length) + protected function formattingSignature(int $element): string { - $this->stack = array_slice($this->stack, 0, $length); + if (isset($this->formattingSignatures[$element])) { + return $this->formattingSignatures[$element]; + } + + $markup = $this->arena->value[$element]; + + if (strpos($markup, '{{') !== false) { + $attributes = $markup; + } else { + $attributes = $this->handle($element)->attributes(); + ksort($attributes); + $attributes = serialize($attributes); + } + + return $this->formattingSignatures[$element] = $this->arena->namespace($element) + .'|'.$this->arena->name[$element] + .'|'.$attributes; } - protected function clearActiveFormattingToLastMarker() + protected function clearActiveFormattingToLastMarker(): void { for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { if ($this->activeFormatting[$index] === null) { $this->activeFormatting = array_slice($this->activeFormatting, 0, $index); + $this->activeSignatureCounts = array_pop($this->activeSignatureCountStack) ?? []; return; } } } - protected function reconstructedCopy($element) + protected function reconstructedCopy(int $element): int { - return $this->arena->element( + $copy = $this->arena->element( $this->arena->name[$element], $this->arena->value[$element], false, $this->arena->namespace($element), true ); + + if (isset($this->arena->sourceStart[$element])) { + $this->arena->sourceStart[$copy] = $this->arena->sourceStart[$element]; + } + + if (isset($this->formattingSignatures[$element])) { + $this->formattingSignatures[$copy] = $this->formattingSignatures[$element]; + } + + return $copy; } } diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php index 2129bf6dc95..e0d4bc75fcf 100644 --- a/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php @@ -4,22 +4,18 @@ use Statamic\View\Antlers\Language\Analyzers\Html\Arena; -/** - * @internal - */ +/** @internal */ trait AnchorsSourceNodes { - protected function arenaNodeIsDescendantOf($node, $ancestor) + protected function arenaNodeIsDescendantOf(int $node, int $ancestor): bool { - $seen = []; + $remaining = count($this->arena->kinds); for ($parent = $this->arena->parent($node); $parent !== null; $parent = $this->arena->parent($parent)) { - if (isset($seen[$parent])) { + if ($remaining-- < 0) { throw new \LogicException('Cycle detected while resolving an HTML arena ancestor.'); } - $seen[$parent] = true; - if ($parent === $ancestor) { return true; } @@ -28,12 +24,12 @@ protected function arenaNodeIsDescendantOf($node, $ancestor) return false; } - protected function arenaNodeHasSourceAnchoredAncestor($node) + protected function arenaNodeHasSourceAnchoredAncestor(int $node): bool { return $this->arenaNodeAncestorSourceAnchor($node) !== null; } - protected function arenaNodeAncestorSourceAnchor($node) + protected function arenaNodeAncestorSourceAnchor(int $node): ?int { $seen = []; @@ -53,13 +49,13 @@ protected function arenaNodeAncestorSourceAnchor($node) } protected function anchorRecoveredSourceNode( - $node, - $container, - $sourceTable, - $sourceTableTail, - $previous = null, - $sourcePositionAnchor = null - ) { + int $node, + int $container, + ?int $sourceTable, + ?int $sourceTableTail, + ?int $previous = null, + ?int $sourcePositionAnchor = null + ): void { $containerIsSynthetic = (bool) ($this->arena->kind($container) & Arena::SYNTHETIC); $sourcePositionAnchor = $sourcePositionAnchor ?? ($this->arena->sourceAnchor[$container] ?? null); @@ -74,7 +70,6 @@ protected function anchorRecoveredSourceNode( $sourcePositionTarget = $this->arena->anchorTarget[$sourcePositionAnchor] ?? null; } - // Continue an existing source-position anchor without anchoring the same subtree twice. if ($sourcePositionTarget !== null) { $ancestorSourceAnchor = $this->arenaNodeAncestorSourceAnchor($node); $ancestorAlreadyInsideSourceTarget = $ancestorSourceAnchor !== null @@ -96,7 +91,6 @@ protected function anchorRecoveredSourceNode( } } - // Content recovered outside a table still renders at its authored position inside it. if ($sourceTable !== null && ! $this->arenaNodeIsDescendantOf($node, $sourceTable) && ! $this->arenaNodeHasSourceAnchoredAncestor($node)) { @@ -115,7 +109,6 @@ protected function anchorRecoveredSourceNode( $previous = $this->arena->previous($node); } - // A synthetic container has no authored opening tag; follow its previous child's anchor. if ($containerIsSynthetic && $previous !== null && isset($this->arena->sourceAnchor[$previous])) { @@ -133,7 +126,7 @@ protected function anchorRecoveredSourceNode( } } - protected function anchorAfterSourcePositionTail($node, $sourceContainer) + protected function anchorAfterSourcePositionTail(int $node, int $sourceContainer): bool { $sourcePositionAnchor = $this->arena->sourceAnchor[$sourceContainer] ?? null; @@ -147,11 +140,17 @@ protected function anchorAfterSourcePositionTail($node, $sourceContainer) return false; } - $sourceTail = $this->arena->lastChild($anchorParent); + $sourceTail = $sourcePositionAnchor; + + for ($next = $this->arena->next($sourceTail); $next !== null; $next = $this->arena->next($next)) { + if (($this->arena->kind($next) & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + break; + } - if ($sourceTail === null - || $sourceTail === $sourcePositionAnchor - || ($this->arena->kind($sourceTail) & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + $sourceTail = $next; + } + + if ($sourceTail === $sourcePositionAnchor) { return false; } @@ -160,7 +159,7 @@ protected function anchorAfterSourcePositionTail($node, $sourceContainer) return true; } - protected function anchorArenaNodeForMove($node) + protected function anchorArenaNodeForMove(int $node): void { if ($this->arena->parent($node) === null) { return; @@ -174,9 +173,4 @@ protected function anchorArenaNodeForMove($node) $this->arena->detach($node); } - - protected function markDirtyFromContainer() - { - $this->markDirty(); - } } diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php index 6f89be98844..26c6f7358ef 100644 --- a/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php @@ -2,14 +2,16 @@ namespace Statamic\View\Antlers\Language\Analyzers\Html\Concerns; +use Statamic\View\Antlers\Language\Analyzers\Html\Arena; use Statamic\View\Antlers\Language\Analyzers\Html\TableMode; use Statamic\View\Antlers\Language\Nodes\AntlersNode as LanguageAntlersNode; +use Statamic\View\Instrumentation\ComponentPrefixes; use Statamic\View\Instrumentation\HtmlSpec; /** @internal */ trait BuildsDocumentGraph { - protected function nextTextBoundary($position, $sourceLength) + protected function nextTextBoundary(int $position, int $sourceLength): int { $nextMarkup = strpos($this->source, '<', $position); @@ -30,7 +32,7 @@ protected function nextTextBoundary($position, $sourceLength) return min($nextMarkup, $nextAntlers); } - protected function startsBogusClosingTag($start, $next) + protected function startsBogusClosingTag(int $start, ?string $next): bool { if ($next !== '/') { return false; @@ -49,7 +51,7 @@ protected function startsBogusClosingTag($start, $next) return ! isset($this->regionEnds[$start + 2]); } - protected function startsLiteralLessThan($start, $next) + protected function startsLiteralLessThan(int $start, ?string $next): bool { if ($next === null || HtmlSpec::isAsciiAlpha($next)) { return false; @@ -62,7 +64,7 @@ protected function startsLiteralLessThan($start, $next) return ! isset($this->regionEnds[$start + 1]); } - protected function endTagContext($name) + protected function endTagContext(string $name): array { $processAsHtml = ! $this->currentContainerIsForeign(); $foreignMatch = null; @@ -91,7 +93,7 @@ protected function endTagContext($name) return [false, null]; } - protected function closesCurrentTextElement($name) + protected function closesCurrentTextElement(string $name): bool { if ($this->textElement === null) { return false; @@ -100,7 +102,7 @@ protected function closesCurrentTextElement($name) return strcasecmp((string) $this->arena->name[$this->textElement], $name) === 0; } - protected function shouldCloseSelectBeforeEndTag($name) + protected function shouldCloseSelectBeforeEndTag(string $name): bool { if ($this->openSelect === null || ! $this->selectInTable) { return false; @@ -113,7 +115,7 @@ protected function shouldCloseSelectBeforeEndTag($name) return isset(self::$selectTableElements[$name]); } - protected function shouldIgnoreEndTagInSelect($name, $closesCurrentTextElement) + protected function shouldIgnoreEndTagInSelect(string $name, bool $closesCurrentTextElement): bool { if ($this->openSelect === null || ! $this->isInHtmlSelectMode()) { return false; @@ -126,7 +128,7 @@ protected function shouldIgnoreEndTagInSelect($name, $closesCurrentTextElement) return ! in_array($name, ['option', 'optgroup', 'select', 'template'], true); } - protected function shouldCloseColgroupBeforeEndTag($name, $processAsHtml) + protected function shouldCloseColgroupBeforeEndTag(string $name, bool $processAsHtml): bool { if (! $processAsHtml) { return false; @@ -139,7 +141,7 @@ protected function shouldCloseColgroupBeforeEndTag($name, $processAsHtml) return ! in_array($name, ['col', 'colgroup', 'template'], true); } - protected function isHtmlFormEndTag($name, $processAsHtml) + protected function isHtmlFormEndTag(string $name, bool $processAsHtml): bool { if (! $processAsHtml || $name !== 'form') { return false; @@ -148,7 +150,7 @@ protected function isHtmlFormEndTag($name, $processAsHtml) return ! $this->hasOpenHtmlTemplate(); } - protected function consumeFormEndTag($start, $end, $markup) + protected function consumeFormEndTag(int $start, int $end, string $markup): void { if ($this->formElement === null) { $this->appendOpaqueMarkup($start, $end, $markup); @@ -174,7 +176,7 @@ protected function consumeFormEndTag($start, $end, $markup) } if ($formIndex !== false) { - array_splice($this->stack, $formIndex, 1); + $this->removeOpenElementAt($formIndex); } } @@ -182,7 +184,7 @@ protected function consumeFormEndTag($start, $end, $markup) $this->appendOpaqueMarkup($start, $end, $markup); } - protected function closeMatchedElement($match, $name, $markup, $start, $end) + protected function closeMatchedElement(int $match, string $name, string $markup, int $start, int $end): void { $matchedElement = $this->stack[$match]; @@ -190,7 +192,7 @@ protected function closeMatchedElement($match, $name, $markup, $start, $end) $this->attachEmbeddedRegions($matchedElement, $start, $end, true); } - $this->setElementClosingMarkup($matchedElement, $markup); + $this->setElementClosingMarkup($matchedElement, $markup, $start); if ($matchedElement === $this->textElement) { $this->textElement = null; @@ -214,7 +216,7 @@ protected function closeMatchedElement($match, $name, $markup, $start, $end) } } - protected function closeDynamicElement($start, $end, $markup) + protected function closeDynamicElement(int $start, int $end, string $markup): bool { for ($index = count($this->stack) - 1; $index >= 0; $index--) { $element = $this->stack[$index]; @@ -224,7 +226,7 @@ protected function closeDynamicElement($start, $end, $markup) } $this->attachEmbeddedRegions($element, $start, $end, true); - $this->setElementClosingMarkup($element, $markup); + $this->setElementClosingMarkup($element, $markup, $start); $this->sliceOpenStack($index); return true; @@ -233,9 +235,9 @@ protected function closeDynamicElement($start, $end, $markup) return false; } - protected function consumeStartTagInSelectMode($name, $start, $end, $markup) + protected function consumeStartTagInSelectMode(?string $name, int $start, int $end, string $markup): bool { - if ($this->selectInTable && isset(self::$selectTableElements[$name])) { + if ($this->selectInTable && isset(self::$selectTableElements[$name ?? ''])) { $this->closeOpenElement('select'); return false; @@ -263,7 +265,7 @@ protected function consumeStartTagInSelectMode($name, $start, $end, $markup) return true; } - protected function shouldExitForeignContentBeforeStart($container, $name, $markup) + protected function shouldExitForeignContentBeforeStart(int $container, ?string $name, string $markup): bool { if ($container === 0 || ! $this->isForeignElement($container)) { return false; @@ -272,7 +274,7 @@ protected function shouldExitForeignContentBeforeStart($container, $name, $marku return $this->shouldExitForeignContent($name, $markup); } - protected function shouldIgnoreFrameStart($name, $isHtmlElement, $processStartAsHtml) + protected function shouldIgnoreFrameStart(?string $name, bool $isHtmlElement, bool $processStartAsHtml): bool { if (! $isHtmlElement || ! $processStartAsHtml) { return false; @@ -281,7 +283,7 @@ protected function shouldIgnoreFrameStart($name, $isHtmlElement, $processStartAs return in_array($name, ['frame', 'frameset'], true); } - protected function initialFosterState($name, $namespace, $sourceContainer, $processStartAsHtml) + protected function initialFosterState(?string $name, string $namespace, int $sourceContainer, bool $processStartAsHtml): bool { if ($processStartAsHtml && $name !== null) { return $this->prepareTableStart($name); @@ -294,7 +296,7 @@ protected function initialFosterState($name, $namespace, $sourceContainer, $proc return $namespace !== 'html' && $this->shouldFosterContent(); } - protected function isFosteredHiddenInput($element, $name, $foster) + protected function isFosteredHiddenInput(int $element, ?string $name, bool $foster): bool { if (! $foster || $name !== 'input') { return false; @@ -304,13 +306,13 @@ protected function isFosteredHiddenInput($element, $name, $foster) } protected function insertStartElement( - $element, - $sourceContainer, - $sourcePositionAnchor, - $sourceTable, - $sourceTableTail, - $foster - ) { + int $element, + int $sourceContainer, + ?int $sourcePositionAnchor, + ?int $sourceTable, + ?int $sourceTableTail, + bool $foster + ): void { if ($foster) { $this->fosterNode($element, $sourceContainer); @@ -331,18 +333,18 @@ protected function insertStartElement( } protected function pushStartElement( - $element, - $name, - $namespace, - $selfClosing, - $selectInTable, - $tableForm - ) { + int $element, + ?string $name, + string $namespace, + bool $selfClosing, + bool $selectInTable, + bool $tableForm + ): void { if ($tableForm || ! $this->shouldPushElement($name, $namespace, $selfClosing)) { return; } - $this->stack[] = $element; + $this->pushOpenElement($element); if ($namespace !== 'html') { return; @@ -352,10 +354,6 @@ protected function pushStartElement( $this->pushActiveFormattingElement($element); } - if ($name === 'table') { - $this->hasSeenTable = true; - } - if ($name === 'template') { $this->templateInsertionModes[$element] = TableMode::Template; } @@ -365,8 +363,8 @@ protected function pushStartElement( $this->selectInTable = $selectInTable; } - if (isset(self::$formattingMarkerElements[$name])) { - $this->activeFormatting[] = null; + if (isset(self::$formattingMarkerElements[$name ?? ''])) { + $this->pushActiveFormattingMarker(); } if ($name === 'script') { @@ -378,7 +376,7 @@ protected function pushStartElement( } } - protected function build(?array $nodes) + protected function build(?array $nodes): void { if ($nodes !== null) { $this->indexAntlersByteRegions($nodes); @@ -411,7 +409,7 @@ protected function build(?array $nodes) if ($this->source[$position] !== '<') { $next = $this->nextTextBoundary($position, $sourceLength); - $this->appendParsedText(substr($this->source, $position, $next - $position)); + $this->appendParsedText(substr($this->source, $position, $next - $position), $position); $position = $next; continue; @@ -421,13 +419,16 @@ protected function build(?array $nodes) } $this->regionEnds = []; - $this->stack = []; + $this->resetOpenStack(); $this->activeFormatting = []; - $this->fosterCharacterTails = []; + $this->activeSignatureCounts = []; + $this->activeSignatureCountStack = []; + $this->formattingSignatures = []; $this->templateInsertionModes = []; + $this->insertionChains = []; } - protected function consumeMarkup($start) + protected function consumeMarkup(int $start): int { $next = $this->source[$start + 1] ?? null; @@ -444,7 +445,7 @@ protected function consumeMarkup($start) } if ($this->startsLiteralLessThan($start, $next)) { - $this->appendParsedText('<'); + $this->appendParsedText('<', $start); return $start + 1; } @@ -474,7 +475,7 @@ protected function consumeMarkup($start) return $this->consumeStartTag($start, $end, $markup, $name); } - protected function consumeStartTag($start, $end, $markup, $name) + protected function consumeStartTag(int $start, int $end, string $markup, ?string $name): int { $sourcePositionContainer = $this->currentContainer(); $sourcePositionAnchor = $this->arena->sourceAnchor[$sourcePositionContainer] ?? null; @@ -552,6 +553,7 @@ protected function consumeStartTag($start, $end, $markup, $name) $elementName = $this->elementNameForNamespace($name, $namespace); $element = $this->arena->element($elementName, $markup, $selfClosing, $namespace); $this->elementSourceStarts[$element] = $start; + $this->arena->sourceStart[$element] = $start; if ($isHtmlElement && $name === 'form' && ! $this->hasOpenHtmlTemplate()) { $this->formElement = $element; } @@ -585,7 +587,7 @@ protected function consumeStartTag($start, $end, $markup, $name) return $end; } - protected function consumeEndTag($start, $end, $markup, $name) + protected function consumeEndTag(int $start, int $end, string $markup, string|false|null $name): int { if (! is_string($name)) { if ($name === null && $this->closeDynamicElement($start, $end, $markup)) { @@ -617,7 +619,7 @@ protected function consumeEndTag($start, $end, $markup, $name) } if ($this->shouldCloseColgroupBeforeEndTag($name, $processAsHtml)) { - array_pop($this->stack); + $this->popOpenElement(); } if ($processAsHtml) { @@ -661,7 +663,7 @@ protected function consumeEndTag($start, $end, $markup, $name) $this->arena->append($container, $paragraph); } - $this->stack[] = $paragraph; + $this->pushOpenElement($paragraph); $match = count($this->stack) - 1; } } elseif ($processAsHtml) { @@ -679,7 +681,7 @@ protected function consumeEndTag($start, $end, $markup, $name) return $end; } - protected function appendRecoveredBreak($start, $end, $markup) + protected function appendRecoveredBreak(int $start, int $end, string $markup): void { $sourceContainer = $this->currentContainer(); [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); @@ -688,10 +690,15 @@ protected function appendRecoveredBreak($start, $end, $markup) if ($this->activeFormatting && $this->characterTokenUsesHtmlRules($this->currentContainer())) { $this->reconstructActiveFormatting(); + + if ($foster && $this->tableMode() === TableMode::Normal) { + $foster = false; + } } $element = $this->arena->element('br', $markup, false, 'html'); $this->elementSourceStarts[$element] = $start; + $this->arena->sourceStart[$element] = $start; $this->insertStartElement( $element, @@ -703,11 +710,11 @@ protected function appendRecoveredBreak($start, $end, $markup) ); if ($this->regionStarts) { - $this->attachEmbeddedRegions($element, $start, $end); + $this->attachEmbeddedRegions($element, $start, $end, true); } } - protected function appendOpaqueMarkup($start, $end, $markup, $forcedType = null) + protected function appendOpaqueMarkup(int $start, int $end, string $markup, ?string $forcedType = null): void { [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); $type = $forcedType; @@ -717,6 +724,7 @@ protected function appendOpaqueMarkup($start, $end, $markup, $forcedType = null) } $opaque = $this->arena->opaque($markup, $type); + $this->arena->sourceStart[$opaque] = $start; $container = $this->currentContainer(); $this->arena->append($container, $opaque); $this->anchorRecoveredSourceNode($opaque, $container, $sourceTable, $sourceTableTail); @@ -740,7 +748,7 @@ protected function appendOpaqueMarkup($start, $end, $markup, $forcedType = null) } } - protected function appendTextWithAntlers($start, $end, $container) + protected function appendTextWithAntlers(int $start, int $end, int $container): void { if ($this->shouldReconstructFormattingInPlaintext()) { $this->reconstructActiveFormatting(); @@ -762,12 +770,12 @@ protected function appendTextWithAntlers($start, $end, $container) $next = $end; } - $this->arena->append($container, $this->arena->text(substr($this->source, $position, $next - $position))); + $this->arena->append($container, $this->arena->text(substr($this->source, $position, $next - $position), $position)); $position = $next; } } - protected function shouldCloseImpliedElementsBeforeStart($name, $tableForm, $startsInHtmlSelectMode) + protected function shouldCloseImpliedElementsBeforeStart(string $name, bool $tableForm, bool $startsInHtmlSelectMode): bool { if ($tableForm || empty($this->stack)) { return false; @@ -792,7 +800,7 @@ protected function shouldCloseImpliedElementsBeforeStart($name, $tableForm, $sta return $name === 'button'; } - protected function shouldIgnoreFormStart($name, $isHtmlElement) + protected function shouldIgnoreFormStart(?string $name, bool $isHtmlElement): bool { if (! $isHtmlElement || $name !== 'form' || $this->formElement === null) { return false; @@ -801,16 +809,16 @@ protected function shouldIgnoreFormStart($name, $isHtmlElement) return ! $this->hasOpenHtmlTemplate(); } - protected function shouldIgnoreTableStructureStart($name, $isHtmlElement) + protected function shouldIgnoreTableStructureStart(?string $name, bool $isHtmlElement): bool { - if (! $isHtmlElement || ! isset(self::$tableStructureStarts[$name])) { + if (! $isHtmlElement || ! isset(self::$tableStructureStarts[$name ?? ''])) { return false; } return $this->tableStructureMode() === null && ! $this->hasOpenHtmlTemplate(); } - protected function isTableSelectStart($name, $isHtmlElement) + protected function isTableSelectStart(?string $name, bool $isHtmlElement): bool { if (! $isHtmlElement || $name !== 'select') { return false; @@ -830,7 +838,7 @@ protected function isTableSelectStart($name, $isHtmlElement) ); } - protected function isTableFormStart($name, $isHtmlElement) + protected function isTableFormStart(?string $name, bool $isHtmlElement): bool { if (! $isHtmlElement || $name !== 'form') { return false; @@ -839,7 +847,7 @@ protected function isTableFormStart($name, $isHtmlElement) return in_array($this->tableStructureMode(), [TableMode::Table, TableMode::TableBody, TableMode::Row], true); } - protected function prepareFormattingForStart($name, $foster, $tableForm, $startsInHtmlSelectMode) + protected function prepareFormattingForStart(string $name, bool $foster, bool $tableForm, bool $startsInHtmlSelectMode): bool { if ($name === 'nobr' && ! empty($this->activeFormatting)) { $this->reconstructActiveFormatting(); @@ -863,16 +871,16 @@ protected function prepareFormattingForStart($name, $foster, $tableForm, $starts $formatting = $this->activeFormatting[$formattingIndex]; $stackDepth = count($this->stack); $this->closeFormattingElement($name); - $activeIndex = array_search($formatting, $this->activeFormatting, true); + $activeIndex = $this->activeFormattingPosition($formatting); if ($activeIndex !== false) { - array_splice($this->activeFormatting, $activeIndex, 1); + $this->removeActiveFormattingAt($activeIndex); } - $stackIndex = array_search($formatting, $this->stack, true); + $stackIndex = $this->openStackIndex($formatting); if ($stackIndex !== false) { - array_splice($this->stack, $stackIndex, 1); + $this->removeOpenElementAt($stackIndex); } if ($this->shouldRefosterAfterStackChange($foster, $stackDepth)) { @@ -893,7 +901,7 @@ protected function prepareFormattingForStart($name, $foster, $tableForm, $starts return $foster; } - protected function shouldRefosterAfterStackChange($foster, $previousStackDepth) + protected function shouldRefosterAfterStackChange(bool $foster, int $previousStackDepth): bool { if ($foster || count($this->stack) === $previousStackDepth) { return false; @@ -902,7 +910,7 @@ protected function shouldRefosterAfterStackChange($foster, $previousStackDepth) return $this->shouldFosterContent(); } - protected function shouldReconstructFormattingBeforeStart($name, $processStartAsHtml) + protected function shouldReconstructFormattingBeforeStart(?string $name, bool $processStartAsHtml): bool { if ($name === null || ! $processStartAsHtml || empty($this->activeFormatting)) { return false; @@ -919,26 +927,27 @@ protected function shouldReconstructFormattingBeforeStart($name, $processStartAs return ! in_array($name, ['rb', 'rp', 'rt', 'rtc'], true); } - protected function elementNameForNamespace($name, $namespace) + protected function elementNameForNamespace(?string $name, string $namespace): ?string { - if ($namespace !== 'svg' || ! isset(self::$svgTagNames[$name])) { + if ($namespace !== 'svg' || ! isset(self::$svgTagNames[$name ?? ''])) { return $name; } return self::$svgTagNames[$name]; } - protected function shouldPushElement($name, $namespace, $selfClosing) + protected function shouldPushElement(?string $name, string $namespace, bool $selfClosing): bool { if ($namespace !== 'html' - || \Statamic\View\Instrumentation\ComponentPrefixes::matches($name, $this->componentPrefixes)) { + || $name === null + || ComponentPrefixes::matches($name, $this->componentPrefixes)) { return ! $selfClosing; } - return $name === null || ! isset(self::$voidElements[$name]); + return ! isset(self::$voidElements[$name]); } - protected function startsTextElement($name) + protected function startsTextElement(?string $name): bool { if ($name === null) { return false; @@ -947,7 +956,7 @@ protected function startsTextElement($name) return isset(self::$rawTextElements[$name]) || $name === 'plaintext'; } - protected function opaqueMarkupType($markup) + protected function opaqueMarkupType(string $markup): string { if (strncmp($markup, '') || ($start = strrpos($html, ''); @@ -296,26 +360,14 @@ public static function decode($marker) return $metadata; } - protected function resultCacheKey($template, $view, $commentsAtDocumentRoot) + protected function resultCacheKey(string $template, ?string $view, bool $commentsAtDocumentRoot): string { - $parts = [InstrumentationState::sourceMap()?->fingerprint()]; - - $documentContext = 'nested'; - - if ($commentsAtDocumentRoot) { - $documentContext = 'root'; - } - - $viewPath = ''; - - if (is_string($view)) { - $viewPath = $view; - } - - $parts[] = $documentContext; - $parts[] = $viewPath; - $parts[] = $template; - - return hash('xxh128', serialize($parts)); + return ($commentsAtDocumentRoot ? 'root:' : 'nested:') + .InstrumentationState::lineSeed() + .':'.InstrumentationState::offsetSeed() + .':'.(InstrumentationState::attributesView() ? '1' : '0') + .':'.(InstrumentationState::sourceMap()?->fingerprint() ?? '') + .':'.($view ?? '') + .':'.hash('xxh128', $template); } } diff --git a/src/View/Instrumentation/HtmlSpec.php b/src/View/Instrumentation/HtmlSpec.php index a8fac8475d0..d5b58f7ee18 100644 --- a/src/View/Instrumentation/HtmlSpec.php +++ b/src/View/Instrumentation/HtmlSpec.php @@ -5,11 +5,7 @@ /** @internal */ class HtmlSpec { - /** - * @param string $source - * @return bool - */ - public static function hasExplicitDocumentStructure($source) + public static function hasExplicitDocumentStructure(string $source): bool { return preg_match('/<(?:!doctype[\s>]|html[\s>\/]|head[\s>\/]|body[\s>\/])/i', $source) === 1; } @@ -33,30 +29,22 @@ public static function hasExplicitDocumentStructure($source) 'pre' => true, 'listing' => true, 'plaintext' => true, ]; - /** - * @param string $char - * @return bool - */ - public static function isHtmlSpace($char) + public static function isHtmlSpace(string $char): bool { return $char === ' ' || $char === "\t" || $char === "\n" || $char === "\r" || $char === "\f"; } - public static function isTagNameDelimiter($char) + public static function isTagNameDelimiter(?string $char): bool { return $char === null || self::isHtmlSpace($char) || $char === '>' || $char === '/'; } - public static function isAttributeNameDelimiter($char) + public static function isAttributeNameDelimiter(?string $char): bool { return self::isTagNameDelimiter($char) || $char === '='; } - /** - * @param mixed $name - * @return bool - */ - public static function isValidName($name) + public static function isValidName(mixed $name): bool { if (! is_string($name)) { return false; @@ -73,11 +61,7 @@ public static function isValidName($name) return ! preg_match('/[\x00-\x20\x7F"\'<>\/=]/', $name); } - /** - * @param string|null $char - * @return bool - */ - public static function isAsciiAlpha($char) + public static function isAsciiAlpha(?string $char): bool { if ($char === null) { return false; @@ -91,11 +75,10 @@ public static function isAsciiAlpha($char) } /** - * @param mixed $value * @param array $dynamicDelimiters * @return string[] */ - public static function classList($value, array $dynamicDelimiters = [['{{', '}}']]) + public static function classList(mixed $value, array $dynamicDelimiters = [['{{', '}}']]): array { $value = (string) $value; $classes = []; diff --git a/src/View/Instrumentation/InstrumentationManager.php b/src/View/Instrumentation/InstrumentationManager.php index 929165248b4..810bd60c581 100644 --- a/src/View/Instrumentation/InstrumentationManager.php +++ b/src/View/Instrumentation/InstrumentationManager.php @@ -2,7 +2,10 @@ namespace Statamic\View\Instrumentation; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Config; use InvalidArgumentException; +use Statamic\View\Antlers\Language\Nodes\AntlersNode; use Statamic\View\Antlers\Language\Runtime\RuntimeConfiguration; use Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract; use Statamic\View\Antlers\Language\Runtime\Tracing\RuntimeTracerContract; @@ -17,16 +20,33 @@ class InstrumentationManager protected WeakMap $runtimeConfigurations; /** @var array */ - protected $configured = []; + protected array $configured = []; /** @var array */ - protected $antlersPieces = []; + protected array $antlersPieces = []; + + /** @var array{0: mixed, 1: HtmlInstrumentation|null}|null */ + protected ?array $configuredState = null; + + /** @var HtmlInstrumentation[]|null */ + protected ?array $htmlInstrumenters = null; + + /** @var string[] */ + protected array $cleanedWith = []; + + protected ?string $cleanedHash = null; + + protected int $cleanedLength = -1; + + protected int $misplacedMarkerRemovals = 0; + + protected int $removedMisplacedMarkerBytes = 0; /** @var callable[] */ - protected $runtimePreparsers = []; + protected array $runtimePreparsers = []; /** @var NodeVisitorContract[] */ - protected $runtimeVisitors = []; + protected array $runtimeVisitors = []; public function __construct() { @@ -42,6 +62,13 @@ public function preparse(callable $preparser): void $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->preparse($preparser)); } + public function removePreparser(callable $preparser): void + { + $this->runtimePreparsers = $this->without($this->runtimePreparsers, $preparser); + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removePreparser($preparser)); + } + public function addVisitor(NodeVisitorContract $visitor): void { if (! in_array($visitor, $this->runtimeVisitors, true)) { @@ -51,12 +78,15 @@ public function addVisitor(NodeVisitorContract $visitor): void $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->addVisitor($visitor)); } - /** - * @param object|string $piece - * @param string[]|null $engines - * @return $this - */ - public function register($piece, ?array $engines = null) + public function removeVisitor(NodeVisitorContract $visitor): void + { + $this->runtimeVisitors = $this->without($this->runtimeVisitors, $visitor); + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removeVisitor($visitor)); + } + + /** @param string[]|null $engines */ + public function register(object|string $piece, ?array $engines = null): static { $engines = $this->normalizeEngines($engines); $abstract = null; @@ -84,10 +114,39 @@ public function register($piece, ?array $engines = null) } /** - * @param array $config - * @return HtmlInstrumentation + * @template TReturn + * + * @param callable(): TReturn $callback + * @param string[]|null $engines + * @return TReturn */ - public function configuredHtmlInstrumentation(array $config) + public function using(object|string $piece, callable $callback, ?array $engines = null): mixed + { + if (is_string($piece)) { + $piece = $this->configured[$piece] ?? app($piece); + } + + $registered = in_array($piece, $this->antlersPieces, true); + + $this->register($piece, $engines); + + try { + return $callback(); + } finally { + if (! $registered) { + $this->forget($piece, $engines); + } + } + } + + /** @return Collection> */ + public function regions(string $html): Collection + { + return HtmlInstrumentation::regionsIn($html, $this->htmlInstrumenters()); + } + + /** @param array $config */ + public function configuredHtmlInstrumentation(array $config): HtmlInstrumentation { $key = $this->configurationKey($config); @@ -95,26 +154,29 @@ public function configuredHtmlInstrumentation(array $config) return HtmlInstrumentation::fromConfig($config); } - return $this->configured[$key] ??= HtmlInstrumentation::fromConfig($config); + if (isset($this->configured[$key])) { + return $this->configured[$key]; + } + + foreach (array_keys($this->configured) as $configured) { + if (str_starts_with($configured, 'html:')) { + unset($this->configured[$configured]); + } + } + + return $this->configured[$key] = HtmlInstrumentation::fromConfig($config); } - /** - * @param array $config - * @return string|null - */ - protected function configurationKey(array $config) + /** @param array $config */ + protected function configurationKey(array $config): ?string { $fingerprint = HtmlInstrumentation::configurationFingerprint($config); return $fingerprint === null ? null : 'html:'.$fingerprint; } - /** - * @param object|string $piece - * @param string[]|null $engines - * @return $this - */ - public function forget($piece, ?array $engines = null) + /** @param string[]|null $engines */ + public function forget(object|string $piece, ?array $engines = null): static { $engines = $this->normalizeEngines($engines); @@ -134,8 +196,9 @@ public function forget($piece, ?array $engines = null) $wantsAntlers = $engines === null || in_array(static::ENGINE_ANTLERS, $engines, true); - if ($wantsAntlers) { + if ($wantsAntlers && in_array($piece, $this->antlersPieces, true)) { $this->antlersPieces = $this->without($this->antlersPieces, $piece); + $this->htmlInstrumenters = null; $this->removeFromAntlers($piece); } @@ -143,22 +206,38 @@ public function forget($piece, ?array $engines = null) return $this; } - /** @return $this */ - public function flush() + public function flush(): static { $antlers = $this->antlersPieces; + $preparsers = $this->runtimePreparsers; + $visitors = $this->runtimeVisitors; $this->configured = []; $this->antlersPieces = []; + $this->runtimePreparsers = []; + $this->runtimeVisitors = []; + $this->htmlInstrumenters = null; + $this->cleanedWith = []; + $this->cleanedHash = null; foreach ($antlers as $piece) { $this->removeFromAntlers($piece); } + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($preparsers, $visitors) { + foreach ($preparsers as $preparser) { + $config->removePreparser($preparser); + } + + foreach ($visitors as $visitor) { + $config->removeVisitor($visitor); + } + }); + return $this; } - protected function removeFromAntlers($piece): void + protected function removeFromAntlers(object $piece): void { $this->applyToAntlers(function (RuntimeConfiguration $config) use ($piece) { if ($piece instanceof HtmlInstrumentation) { @@ -168,14 +247,14 @@ protected function removeFromAntlers($piece): void } $config->traceManager?->removeTracer($piece); - }); + }, ! $piece instanceof HtmlInstrumentation); } - /** @return void */ - protected function rememberAntlersPiece($piece) + protected function rememberAntlersPiece(HtmlInstrumentation|RuntimeTracerContract|TracerContract $piece): void { if (! in_array($piece, $this->antlersPieces, true)) { $this->antlersPieces[] = $piece; + $this->htmlInstrumenters = null; } } @@ -185,22 +264,17 @@ protected function rememberAntlersPiece($piece) * @param T[] $items * @return T[] */ - protected function without(array $items, $needle) + protected function without(array $items, mixed $needle): array { return array_values(array_filter($items, fn ($item) => $item !== $needle)); } - /** @return void */ - public function applyTo(RuntimeConfiguration $runtimeConfiguration) + public function applyTo(RuntimeConfiguration $runtimeConfiguration): void { - // Removing our tracers must preserve tracing enabled by the parser itself. $this->runtimeConfigurations[$runtimeConfiguration] ??= $runtimeConfiguration->isTracingEnabled; - $config = config('statamic.antlers.instrumentation', []); - - if (is_array($config) && ($config['enabled'] ?? false)) { - $markerConfig = array_intersect_key($config, array_flip(['comments', 'prefix', 'attributes'])); - $runtimeConfiguration->preparse($this->configuredHtmlInstrumentation($markerConfig)); + if (($configured = $this->configuredInstrumenter()) !== null) { + $runtimeConfiguration->preparse($configured); } foreach ($this->runtimePreparsers as $preparser) { @@ -222,12 +296,190 @@ public function applyTo(RuntimeConfiguration $runtimeConfiguration) } } + public function sync(): void + { + $this->configuredInstrumenter(); + } + + public function instrumenting(): bool + { + return $this->htmlInstrumenters() !== []; + } + + public function removeMarkers(string $html): string + { + return HtmlInstrumentation::removeMarkers($html, $this->htmlInstrumenters()); + } + + public function containsMarkers(string $html): bool + { + return HtmlInstrumentation::containsMarkers($html, $this->htmlInstrumenters()); + } + + public function tagBody(AntlersNode $node, bool $composesOutput = false): TagBody + { + $instrumenters = $this->htmlInstrumenters(); + $key = $this->tagBodyKey($instrumenters, $composesOutput); + + if ($node->tagBody !== null && $node->tagBody[0] === $key) { + return $node->tagBody[1]; + } + + $body = TagBody::from((string) $node->runtimeContent, $instrumenters, $composesOutput); + $node->tagBody = [$key, $body]; + + return $body; + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function tagBodyKey(array $instrumenters, bool $composesOutput): string + { + $mode = $composesOutput ? 'composes' : 'clean'; + + return $mode.':'.implode("\0", $this->fingerprints($instrumenters)); + } + + /** @param HtmlInstrumentation[]|null $instrumenters */ + public function removeMisplacedMarkers(string $html, ?array $instrumenters = null): string + { + $instrumenters ??= $this->htmlInstrumenters(); + + if ($instrumenters === []) { + return $html; + } + + if (strlen($html) === $this->cleanedLength + && $this->cleanedCovers($instrumenters) + && hash('xxh3', $html) === $this->cleanedHash) { + return $html; + } + + $cleaned = $this->cleanMisplacedMarkers($html, $instrumenters); + + if ($cleaned !== $html) { + $this->misplacedMarkerRemovals++; + $this->removedMisplacedMarkerBytes += strlen($html) - strlen($cleaned); + } + + $this->cleanedWith = $this->fingerprints($instrumenters); + $this->cleanedLength = strlen($cleaned); + $this->cleanedHash = hash('xxh3', $cleaned); + + return $cleaned; + } + + public function misplacedMarkerRemovals(): int + { + return $this->misplacedMarkerRemovals; + } + + public function removedMisplacedMarkerBytes(): int + { + return $this->removedMisplacedMarkerBytes; + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function cleanedCovers(array $instrumenters): bool + { + return array_diff($this->fingerprints($instrumenters), $this->cleanedWith) === []; + } + /** - * @param RuntimeTracerContract|TracerContract $tracer - * @param string[]|null $engines - * @return $this + * @param HtmlInstrumentation[] $instrumenters + * @return string[] */ - protected function registerTracer($tracer, ?array $engines) + protected function fingerprints(array $instrumenters): array + { + return array_map( + fn (HtmlInstrumentation $instrumenter) => $instrumenter->cacheFingerprint(), + $instrumenters + ); + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function cleanMisplacedMarkers(string $html, array $instrumenters): string + { + return HtmlInstrumentation::removeMisplacedMarkers($html, $instrumenters); + } + + public function trimAroundMarkers(string $html): string + { + return HtmlInstrumentation::trimAroundMarkers($html, $this->htmlInstrumenters()); + } + + /** @return array{0: string, 1: string|null} */ + public function splitMarkers(string $output): array + { + $instrumenters = $this->htmlInstrumenters(); + + if ($instrumenters === [] || ! HtmlInstrumentation::containsMarkers($output, $instrumenters)) { + return [trim($output), null]; + } + + $instrumented = HtmlInstrumentation::trimAroundMarkers($output, $instrumenters); + $stripped = HtmlInstrumentation::removeMarkers($instrumented, $instrumenters); + $value = trim($stripped); + + if ($instrumented === $value || $stripped !== $value) { + return [$value, null]; + } + + return [$value, $instrumented]; + } + + /** @return HtmlInstrumentation[] */ + protected function htmlInstrumenters(): array + { + $configured = $this->configuredInstrumenter(); + + if ($this->htmlInstrumenters !== null) { + return $this->htmlInstrumenters; + } + + $instrumenters = array_values(array_filter( + $this->antlersPieces, + fn ($piece) => $piece instanceof HtmlInstrumentation + )); + + if ($configured !== null) { + $instrumenters[] = $configured; + } + + return $this->htmlInstrumenters = $instrumenters; + } + + protected function configuredInstrumenter(): ?HtmlInstrumentation + { + $config = Config::all()['statamic']['antlers']['instrumentation'] ?? []; + + if ($this->configuredState !== null && $this->configuredState[0] === $config) { + return $this->configuredState[1]; + } + + $instrumenter = null; + + if (is_array($config) && ($config['enabled'] ?? false)) { + $instrumenter = $this->configuredHtmlInstrumentation( + array_intersect_key($config, array_flip(['comments', 'prefix', 'attributes', 'metadata', 'componentPrefixes', 'cache'])) + ); + } + + $previous = $this->configuredState[1] ?? null; + $this->configuredState = [$config, $instrumenter]; + + if ($previous !== $instrumenter) { + $this->htmlInstrumenters = null; + + $this->applyToAntlers( + fn (RuntimeConfiguration $runtimeConfiguration) => $runtimeConfiguration->replacePreparser($previous, $instrumenter) + ); + } + + return $instrumenter; + } + + /** @param string[]|null $engines */ + protected function registerTracer(RuntimeTracerContract|TracerContract $tracer, ?array $engines): static { $engines ??= [static::ENGINE_ANTLERS]; @@ -236,17 +488,14 @@ protected function registerTracer($tracer, ?array $engines) $this->applyToAntlers(function (RuntimeConfiguration $config) use ($tracer) { $config->enableTracing()->registerTracer($tracer); - }); + }, true); } return $this; } - /** - * @param string[]|null $engines - * @return $this - */ - protected function registerHtmlInstrumentation(HtmlInstrumentation $instrumentation, ?array $engines) + /** @param string[]|null $engines */ + protected function registerHtmlInstrumentation(HtmlInstrumentation $instrumentation, ?array $engines): static { $wantsAntlers = $engines === null || in_array(static::ENGINE_ANTLERS, $engines, true); @@ -258,12 +507,14 @@ protected function registerHtmlInstrumentation(HtmlInstrumentation $instrumentat return $this; } - /** @return void */ - protected function applyToAntlers(callable $apply) + protected function applyToAntlers(callable $apply, bool $syncTracing = false): void { foreach ($this->runtimeConfigurations as $configuration => $tracingEnabled) { $apply($configuration); - $configuration->isTracingEnabled = $tracingEnabled || $configuration->hasRegisteredTracers(); + + if ($syncTracing) { + $configuration->isTracingEnabled = $tracingEnabled || $configuration->hasRegisteredTracers(); + } } } @@ -271,7 +522,7 @@ protected function applyToAntlers(callable $apply) * @param string[]|null $engines * @return string[]|null */ - protected function normalizeEngines(?array $engines) + protected function normalizeEngines(?array $engines): ?array { if ($engines === null) { return null; diff --git a/src/View/Instrumentation/InstrumentationState.php b/src/View/Instrumentation/InstrumentationState.php index ed85adf1039..03ffcf781bc 100644 --- a/src/View/Instrumentation/InstrumentationState.php +++ b/src/View/Instrumentation/InstrumentationState.php @@ -2,63 +2,112 @@ namespace Statamic\View\Instrumentation; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Instrumentation\Antlers\ComponentSourceMap; /** @internal */ class InstrumentationState { - protected static $componentContentDepth = 0; + const INDICATOR_TWINS = '_io'; - protected static $componentViewDepth = 0; + protected static int $componentViewDepth = 0; - protected static $viewInstrumentationDepth = 0; + protected static int $viewInstrumentationDepth = 0; protected static bool $suppressHtmlInstrumentation = false; protected static ?ComponentSourceMap $sourceMap = null; + protected static int $lineSeed = 1; + + protected static int $offsetSeed = 0; + + protected static bool $attributesView = true; + + protected static ?string $twinsKey = null; + + protected static string $twinsEnvironment = ''; + + /** @var TagBody[] */ + protected static array $tagBodies = []; + + /** @var array */ + protected static array $outputContexts = []; + + public static function twinsKey(): string + { + if (self::$twinsKey === null || self::$twinsEnvironment !== GlobalRuntimeState::$environmentId) { + self::$twinsEnvironment = GlobalRuntimeState::$environmentId; + self::$twinsKey = GlobalRuntimeState::createIndicatorVariable(self::INDICATOR_TWINS); + } + + return self::$twinsKey; + } + public static function sourceMap(): ?ComponentSourceMap { return self::$sourceMap; } - public static function withSourceMap(?ComponentSourceMap $sourceMap, callable $callback) + public static function lineSeed(): int { - $previous = self::$sourceMap; - self::$sourceMap = $sourceMap; + return self::$lineSeed; + } + + public static function offsetSeed(): int + { + return self::$offsetSeed; + } + + public static function attributesView(): bool + { + return self::$attributesView; + } + + /** @param array{0: int, 1: int, 2: bool} $seed */ + public static function withSourceSeed(array $seed, callable $callback): mixed + { + $previous = [self::$lineSeed, self::$offsetSeed, self::$attributesView]; + [self::$lineSeed, self::$offsetSeed, self::$attributesView] = $seed; try { return $callback(); } finally { - self::$sourceMap = $previous; + [self::$lineSeed, self::$offsetSeed, self::$attributesView] = $previous; } } - public static function parsingComponentContent() + public static function withSourceMap(?ComponentSourceMap $sourceMap, callable $callback): mixed { - return self::$componentContentDepth > 0; + $previous = self::$sourceMap; + self::$sourceMap = $sourceMap; + + try { + return $callback(); + } finally { + self::$sourceMap = $previous; + } } - public static function renderingComponentView() + public static function inComponentView(): bool { return self::$componentViewDepth > 0; } - public static function instrumentingView() + public static function instrumenting(): bool { return self::$viewInstrumentationDepth > 0; } - public static function suppressesHtmlInstrumentation(): bool + public static function suppressed(): bool { return self::$suppressHtmlInstrumentation; } - public static function whileRenderingInContext(?HtmlContext $context, callable $callback) + public static function withoutInstrumentation(callable $callback): mixed { $previous = self::$suppressHtmlInstrumentation; - self::$suppressHtmlInstrumentation = $previous - || ($context !== null && ! $context->isSafeForHtmlComments()); + self::$suppressHtmlInstrumentation = true; try { return $callback(); @@ -67,36 +116,76 @@ public static function whileRenderingInContext(?HtmlContext $context, callable $ } } - public static function whileParsingComponentContent(callable $callback) + public static function withinComponentView(callable $callback): mixed { - self::$componentContentDepth++; + self::$componentViewDepth++; try { return $callback(); } finally { - self::$componentContentDepth--; + self::$componentViewDepth--; } } - public static function whileRenderingComponentView(callable $callback) + public static function whileInstrumenting(callable $callback): mixed { - self::$componentViewDepth++; + self::$viewInstrumentationDepth++; try { return $callback(); } finally { - self::$componentViewDepth--; + self::$viewInstrumentationDepth--; } } - public static function whileInstrumentingView(callable $callback) + public static function bufferedOutputIsCommentSafe(): bool { - self::$viewInstrumentationDepth++; + $level = ob_get_level(); + + if ($level === 0) { + return true; + } + + $length = ob_get_length(); + + if ($length === false || $length === 0) { + unset(self::$outputContexts[$level]); + + return true; + } + + $buffered = ob_get_contents(); + [$context, $scannedHash] = self::$outputContexts[$level] ?? [null, '']; + + if ($context === null + || $context->length() > $length + || hash('xxh3', substr($buffered, 0, $context->length())) !== $scannedHash) { + $context = new OutputContext; + } + + $context->advance($buffered); + self::$outputContexts[$level] = [$context, hash('xxh3', substr($buffered, 0, $context->length()))]; + + return $context->isCommentSafe(); + } + + public static function activeTagBody(): ?TagBody + { + return self::$tagBodies === [] ? null : self::$tagBodies[count(self::$tagBodies) - 1]; + } + + public static function withTagBody(?TagBody $body, callable $callback): mixed + { + if ($body === null) { + return $callback(); + } + + self::$tagBodies[] = $body; try { return $callback(); } finally { - self::$viewInstrumentationDepth--; + array_pop(self::$tagBodies); } } } diff --git a/src/View/Instrumentation/MisplacedMarkerRemover.php b/src/View/Instrumentation/MisplacedMarkerRemover.php new file mode 100644 index 00000000000..f227bf84ae0 --- /dev/null +++ b/src/View/Instrumentation/MisplacedMarkerRemover.php @@ -0,0 +1,432 @@ + */ + private static array $compiled = []; + + private readonly int $length; + + private ?string $safeMarkup; + + /** @var string[] */ + private readonly array $literals; + + private readonly string $markerAt; + + private readonly string $anyMarker; + + private readonly string $commentEndOrMarker; + + private readonly string $bogusEndOrMarker; + + private readonly string $cdataEndOrMarker; + + /** @var array */ + private array $nextLiteral = []; + + private string $result = ''; + + private int $copied = 0; + + private int $index = 0; + + /** @param HtmlInstrumentation[] $instrumenters */ + private function __construct(private readonly string $html, string $markers, array $instrumenters) + { + [ + $this->safeMarkup, + $this->literals, + $this->markerAt, + $this->anyMarker, + $this->commentEndOrMarker, + $this->bogusEndOrMarker, + $this->cdataEndOrMarker, + ] = self::$compiled[$markers] ?? self::compile($markers, $instrumenters); + + $this->length = strlen($html); + } + + /** @param HtmlInstrumentation[] $instrumenters */ + public static function remove(string $html, string $markers, array $instrumenters): string + { + $cleaned = (new self($html, $markers, $instrumenters))->run(); + + while ($cleaned !== $html) { + $html = $cleaned; + $cleaned = (new self($html, $markers, $instrumenters))->run(); + } + + return $cleaned; + } + + public static function commentPattern(string $prefix): string + { + return ''; + } + + public static function attributePattern(string $name): string + { + return ' '.preg_quote($name, '/').'="[^"]*"'; + } + + private function run(): string + { + while ($this->index < $this->length) { + $this->skipSafeMarkup(); + + if ($this->index >= $this->length) { + break; + } + + if (($next = strpos($this->html, '<', $this->index)) === false) { + break; + } + + $this->index = $next; + + if (substr_compare($this->html, ' +
{{ title }}
+ + +
Hello
+ + +
Hello
+``` + +## 2. Enabling it + +### Configuration (`config/antlers.php`) + +```php +'instrumentation' => [ + 'enabled' => env('STATAMIC_ANTLERS_INSTRUMENTATION', false), + 'cache' => env('STATAMIC_ANTLERS_INSTRUMENTATION_CACHE', true), // true, a store name, or false + 'comments' => true, // wrap regions in / + 'prefix' => 'antlers', // comment prefix + 'attributes' => [], // attribute names to add to owning elements, e.g. ['data-antlers'] + 'metadata' => [], // extra keys merged into every marker payload + 'componentPrefixes' => [], // extra tag prefixes treated as components (defaults: x- x: s- s: statamic- statamic:) +], + +'tracing' => env('STATAMIC_ANTLERS_TRACING', false), +'tracers' => [], // class names resolved from the container +``` + +Analysis results are cached by template content and Statamic version; `php artisan cache:clear` drops them. The configuration is re-read at the start of each outermost render; when it changes, the manager swaps the configured instrumenter on every tracked runtime configuration. + +### Programmatically, through the `Runtime` facade + +`Statamic\View\Antlers\Language\Facades\Runtime` resolves to the `InstrumentationManager` singleton. Core code uses it for everything except the places that receive the manager by injection (`RuntimeParser`, `RemoveMisplacedMarkersFromResponse`). + +```php +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\HtmlInstrumentation; + +$instrumenter = HtmlInstrumentation::make() + ->comments('trace') // or ->withoutComments() + ->attributes('data-trace') // base64 JSON array of the element's regions; several names allowed + ->jsonAttribute('data-trace-json') // same payload, plain JSON + ->attribute('data-first', fn (array $regions) => $regions[0]['expression']) + ->only('title', 'partial:*') // only these expressions get markers + ->except('partial:nav*') + ->filter(fn (array $metadata, $region) => $metadata['line'] > 10) + ->onSkip(fn (array $metadata, $region, string $reason) => logger($reason)) + ->componentPrefixes('ui:', 'flux:') + ->markersUsing(fn (array $metadata) => ['', '']); // custom markers, see limitations + +$html = Runtime::using($instrumenter, fn () => view('home')->render()); // registered for the callback only + +Runtime::register($instrumenter); // engines default to ['antlers'] +Runtime::register(MyTracer::class); // resolved from the container +Runtime::forget($instrumenter); +Runtime::flush(); // every registered piece, preparser and visitor; the config-driven instrumenter stays while the config enables it +Runtime::preparse($callable); // process-wide preparser; Runtime::removePreparser($callable) withdraws it +Runtime::addVisitor($visitor); // process-wide node visitor; Runtime::removeVisitor($visitor) withdraws it +``` + +`only`, `except` and `filter` stack: a region gets markers when it passes all of them. Several instrumenters can be registered at once; they are applied together in one analysis pass. + +### Helpers on the same facade + +```php +Runtime::instrumenting(); // any HTML instrumenter registered? +Runtime::regions($html); // Collection of decoded regions, see section 4 +Runtime::containsMarkers($html); // fast check, no regex +Runtime::removeMarkers($html); // strip comment and attribute markers +Runtime::removeMisplacedMarkers($html); // the final-pass cleaner, remembers the last cleaned result +Runtime::trimAroundMarkers($html); // trim() that reaches through edge markers +Runtime::splitMarkers($output); // [$clean, $instrumentedTwin|null] +Runtime::tagBody($node, $composesOutput); // internal, see section 6 +``` + +## 3. Instrumenting a template or a string + +Registering an instrumenter (or enabling the config) changes nothing by itself; markers appear when Antlers renders something through one of the paths below. Which path you use decides whether you get markers at all, and what `view`/`line`/`start` mean. + +| Entry point | Markers | `view` | Offsets relative to | +|---|---|---|---| +| `view('page', $data)->render()` | yes | `resources/views/page.antlers.html` | the file, front matter included | +| `Antlers::parse($source, $data, trusted: true)` | yes | absent | the string (line 1) | +| `Antlers::parse($source, $data)` (untrusted, the default) | never | | | +| `Antlers::parser()->parse($source, $data)` outside a view render | never | | | +| `Antlers::parse(..., instrument: false)` / `RuntimeParser::CAPTURED_CONTENT` | never | | | +| Blade `@antlers ... @endantlers` | yes | `storage/framework/views/antlers_.antlers.html` | the extracted block, not the Blade file | +| `$instrumenter->instrument($source)` (no render) | yes, on the source | absent | the string | + +### A view + +```php +$instrumenter = HtmlInstrumentation::make(); + +$html = Runtime::using($instrumenter, fn () => view('pages.show', ['title' => 'Hello'])->render()); +//
Hello
+ +$instrumenter->regions($html)->first(); +// ['expression' => 'title', 'view' => 'resources/views/pages/show.antlers.html', 'line' => 4, 'start' => 22, ...] +``` + +Prefer `using` over `register`: a registered instrumenter stays active for every later render in the process (queue workers, Octane, tests). `using` forgets it afterwards, even when the callback throws, and leaves an instrumenter that was already registered in place. + +### An arbitrary string, rendered + +```php +$output = Runtime::using($instrumenter, fn () => Antlers::parse('
    {{ items }}
  • {{ name }}
  • {{ /items }}
', $data, trusted: true)); + +(string) $output; // with markers when called outside a tag +$output->instrumented(); // with markers, explicitly +Runtime::removeMarkers((string) $output); // clean +``` + +- `trusted: true` is required. Untrusted strings are user content and render under suppression, so they never carry markers. +- `Antlers::parser()->parse()` called outside a view render is also treated as user data (`GlobalRuntimeState::$isEvaluatingUserData` defaults to `true`), so it never gets markers. Inside a view render it does. +- There is no `view`; offsets count from the start of the string. Use `withMetadata(['source' => ...])` on a dedicated instrumenter if the consumer needs to know where the string came from. +- A string with no element around a region (`'{{ title }}'`, `'Hi {{ name }}'`) gets no comment markers; see the root-level limitation. +- Inside a tag, `(string)` on the result is marker-free. Return the object to keep the markers. + +### An arbitrary string, without rendering + +`instrument()` works on Antlers *source*: it wraps each region of the template in markers and returns the template, unrendered. Nothing needs to be registered, and nothing is evaluated. + +```php +$source = HtmlInstrumentation::make()->instrument("Intro {{ title }}\n

{{ title }}

"); +// Intro {{ title }} +//

{{ title }}

+``` + +Use this for tooling that only needs the region map (editors, linters, source maps). Two differences from the runtime path: + +- It marks root-level regions; the runtime (`__invoke`) does not. +- Its output is not meant to be rendered. Rendering it with no instrumenter registered prints the markers as literal comments and value paths (slots, modifiers, comparisons) do not strip them, the same problem as a parser-local instrumenter. Rendering it with the same instrumenter registered is a no-op instrumentation-wise: text that already contains `` or `--!>`), doctypes and other `` / `` / `` bogus comments, and CDATA. Because removing text can fuse neighbouring characters into a new token, it reruns until the output is stable. +- **The HTML engine caps element chains at 512 ancestors** (Chrome's DOM depth limit); deeper nesting reports the outermost 511 names plus the owning element. Adoption-agency runs against thousands of unclosed formatting elements, and anchoring inside templates nested thousands deep, still cost time proportional to the depth per node. +- **Static caching is bypassed while instrumenting**, including nocache regions, so cached-page behaviour cannot be observed with instrumentation on. +- **A tag that throws leaves its node on `GlobalRuntimeState::$globalTagEnterStack`.** Pre-existing in 6.x; it only affects error line seeding, and the instrumentation scope no longer reads that stack. +- **Engine round-trip.** Fixed on 2026-09-30: text inside formatting reconstructed out of a ``/`` was anchored after whatever source anchor happened to be last in the table instead of after its own container's anchor chain (`AnchorsSourceNodes::anchorAfterSourcePositionTail`). Pinned by `DocumentTest::fosteredFormattingProvider`; 200k table-focused fuzz inputs round-trip byte for byte. + +## 11. Where things live + +| Concern | Location | +|---|---| +| Configuration and registration | `InstrumentationManager` via the `Runtime` facade | +| Analysis and marker emission | `HtmlInstrumentation`, `Antlers/TemplateAnalyzer`, `Concerns/EmitsInstrumentation` | +| Marker helpers | `Concerns/RemovesMisplacedMarkers` (`removeMarkers`, `extractMarkers`, `removeMisplacedMarkers`, `trimAroundMarkers`), `HtmlInstrumentation::regions()` | +| Final-pass cleaner | `MisplacedMarkerRemover` (skip-ahead regex built from named pieces, then a tag/attribute/raw-text scanner) | +| Per-render state | `InstrumentationState` (suppression, tag body scope, component and view depth, source seeds) | +| Tag body split and alignment | `TagBody`, memoised on `AntlersNode::$tagBody` | +| Runtime integration | `RuntimeParser::renderText` (alignment, `$instrument`, twin return), `NodeProcessor` (`tagBodyFor`, `tagBodyOutput`, `instrumentedOutput`, slot twins) | +| Render objects | `AntlersString` (`instrumented()`, lazy marker-free cast), `AntlersLoop` | +| Opt-in | `Statamic\Contracts\View\Instrumentation\ComposesRenderedOutput` | +| Response cleanup | `RemoveMisplacedMarkersFromResponse` on `RequestHandled` | +| HTML document engine | `View/Antlers/Language/Analyzers/Html/*` | + +Tests: `tests/View/Instrumentation/*` (behaviour, neutrality, lifecycle, forms, slots, nocache, response), `tests/Antlers/Runtime/HtmlInstrumentationSafetyTest.php` (unsafe contexts and tag bodies), `tests/Antlers/Runtime/SpanTracingTest.php` (tracers), `tests/Antlers/Parser/Html/*` (engine). + +## 12. Verification recipe for this box + +Never run two PHPUnit processes at once; they share the testbench storage and fail with `mkdir(): File exists`. Never run the full suite or `tests/Console/Commands`. + +``` +php vendor/phpunit/phpunit/phpunit tests/View/Instrumentation tests/Antlers/Runtime/HtmlInstrumentationSafetyTest.php tests/Antlers/Runtime/SpanTracingTest.php tests/Antlers/Runtime/LegacyTracerBalanceTest.php tests/Antlers/Runtime/TraceManagerErrorsTest.php tests/StaticCaching/InstrumentationBypassTest.php +php vendor/phpunit/phpunit/phpunit tests/Antlers +php vendor/phpunit/phpunit/phpunit tests/View +php vendor/phpunit/phpunit/phpunit tests/Tags # GlideTest::it_outputs_a_data_url fails on this box regardless +php vendor/phpunit/phpunit/phpunit tests/Forms +php vendor/phpunit/phpunit/phpunit tests/StaticCaching +``` From 29643623897ade0dcd7c2c63a7059caac25a7ff1 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 13 Oct 2026 16:02:06 -0500 Subject: [PATCH 08/19] Collapse the instrumentation result memo into one tier --- .../Concerns/MemoizesResults.php | 54 +++---------------- .../Instrumentation/HtmlInstrumentation.php | 9 +--- 2 files changed, 9 insertions(+), 54 deletions(-) diff --git a/src/View/Instrumentation/Concerns/MemoizesResults.php b/src/View/Instrumentation/Concerns/MemoizesResults.php index e1dd2b4dc26..11640297d2c 100644 --- a/src/View/Instrumentation/Concerns/MemoizesResults.php +++ b/src/View/Instrumentation/Concerns/MemoizesResults.php @@ -14,12 +14,7 @@ trait MemoizesResults protected function flushResultCache(): void { $this->configurationRevision++; - $this->resultCache = []; - - if ($this->cacheScope !== null) { - unset(static::$sharedResultCache[$this->cacheScope]); - $this->cacheScope = null; - } + $this->cacheScope = null; } /** @return array|null */ @@ -49,7 +44,7 @@ public function lastCacheStoreException(): ?Throwable protected function cachedResult(string $key, bool $persistent = true): ?string { - $entry = $this->memoizedEntry($key); + $entry = static::$results[$this->memoKey($key)] ?? null; if ($entry === null && $persistent && ($entry = $this->storedEntry($key)) !== null) { $this->memoize($key, $entry); @@ -80,54 +75,19 @@ protected function storeResult(string $key, string $result, ?array $contexts = n return $result; } - /** @return array{0: string, 1: array|null, 2: string}|null */ - protected function memoizedEntry(string $key): ?array + protected function memoKey(string $key): string { - if ($this->cacheScope !== null) { - return static::$sharedResultCache[$this->cacheScope][$key] ?? null; - } - - return $this->resultCache[$key] ?? null; + return $this->cacheFingerprint().':'.$key; } /** @param array{0: string, 1: array|null, 2: string} $entry */ protected function memoize(string $key, array $entry): void { - if ($this->cacheScope !== null) { - if (! isset(static::$sharedResultCache[$this->cacheScope]) - && count(static::$sharedResultCache) >= static::$sharedResultScopeLimit) { - static::$sharedResultCache = array_slice( - static::$sharedResultCache, - count(static::$sharedResultCache) - static::$sharedResultScopeLimit + 1, - null, - true - ); - } - - static::$sharedResultCache[$this->cacheScope] = $this->evict( - static::$sharedResultCache[$this->cacheScope] ?? [] - ); - - static::$sharedResultCache[$this->cacheScope][$key] = $entry; - - return; - } - - $this->resultCache = $this->evict($this->resultCache); - $this->resultCache[$key] = $entry; - } - - /** - * @param array|null, 2: string}> $cache - * @return array|null, 2: string}> - */ - protected function evict(array $cache): array - { - if (count($cache) < $this->resultCacheLimit) { - return $cache; + if (count(static::$results) >= static::$resultLimit) { + static::$results = array_slice(static::$results, intdiv(static::$resultLimit, 2), null, true); } - return array_slice($cache, intdiv($this->resultCacheLimit, 2), null, true); + static::$results[$this->memoKey($key)] = $entry; } /** @param array|null $contexts */ diff --git a/src/View/Instrumentation/HtmlInstrumentation.php b/src/View/Instrumentation/HtmlInstrumentation.php index af81620fcc6..aec8ae769e7 100644 --- a/src/View/Instrumentation/HtmlInstrumentation.php +++ b/src/View/Instrumentation/HtmlInstrumentation.php @@ -59,12 +59,9 @@ class HtmlInstrumentation protected array $componentPrefixes = ComponentPrefixes::DEFAULTS; /** @var array|null, 2: string}> */ - protected array $resultCache = []; + protected static array $results = []; - /** @var array|null, 2: string}>> */ - protected static array $sharedResultCache = []; - - protected static int $sharedResultScopeLimit = 8; + protected static int $resultLimit = 256; /** @var array> */ protected static array $annotations = []; @@ -77,8 +74,6 @@ class HtmlInstrumentation protected ?string $cacheScope = null; - protected int $resultCacheLimit = 256; - protected bool|string $cacheStore = false; protected bool $cacheStoreFailed = false; From 518b715f7eb613c55bf281c782c6511083109aad Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 13 Oct 2026 18:13:08 -0500 Subject: [PATCH 09/19] Make instrumentation manager mutators chainable --- src/View/Antlers/Language/Facades/Runtime.php | 10 ++++++---- .../InstrumentationManager.php | 20 ++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/View/Antlers/Language/Facades/Runtime.php b/src/View/Antlers/Language/Facades/Runtime.php index 3364628b90c..4c334d748da 100644 --- a/src/View/Antlers/Language/Facades/Runtime.php +++ b/src/View/Antlers/Language/Facades/Runtime.php @@ -6,14 +6,16 @@ use Statamic\View\Instrumentation\InstrumentationManager; /** - * @method static void preparse(callable $callable) - * @method static void removePreparser(callable $callable) - * @method static void addVisitor(\Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract $visitor) - * @method static void removeVisitor(\Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract $visitor) + * @method static InstrumentationManager preparse(callable $callable) + * @method static InstrumentationManager removePreparser(callable $callable) + * @method static InstrumentationManager addVisitor(\Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract $visitor) + * @method static InstrumentationManager removeVisitor(\Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract $visitor) + * @method static InstrumentationManager sync() * @method static InstrumentationManager register(object|string $piece, ?array $engines = null) * @method static InstrumentationManager forget(object|string $piece, ?array $engines = null) * @method static InstrumentationManager flush() * @method static mixed using(object|string $piece, callable $callback, ?array $engines = null) + * @method static void applyTo(\Statamic\View\Antlers\Language\Runtime\RuntimeConfiguration $runtimeConfiguration) * @method static \Illuminate\Support\Collection regions(string $html) * @method static bool instrumenting() * @method static bool containsMarkers(string $html) diff --git a/src/View/Instrumentation/InstrumentationManager.php b/src/View/Instrumentation/InstrumentationManager.php index 810bd60c581..671187707c3 100644 --- a/src/View/Instrumentation/InstrumentationManager.php +++ b/src/View/Instrumentation/InstrumentationManager.php @@ -53,36 +53,44 @@ public function __construct() $this->runtimeConfigurations = new WeakMap; } - public function preparse(callable $preparser): void + public function preparse(callable $preparser): static { if (! in_array($preparser, $this->runtimePreparsers, true)) { $this->runtimePreparsers[] = $preparser; } $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->preparse($preparser)); + + return $this; } - public function removePreparser(callable $preparser): void + public function removePreparser(callable $preparser): static { $this->runtimePreparsers = $this->without($this->runtimePreparsers, $preparser); $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removePreparser($preparser)); + + return $this; } - public function addVisitor(NodeVisitorContract $visitor): void + public function addVisitor(NodeVisitorContract $visitor): static { if (! in_array($visitor, $this->runtimeVisitors, true)) { $this->runtimeVisitors[] = $visitor; } $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->addVisitor($visitor)); + + return $this; } - public function removeVisitor(NodeVisitorContract $visitor): void + public function removeVisitor(NodeVisitorContract $visitor): static { $this->runtimeVisitors = $this->without($this->runtimeVisitors, $visitor); $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removeVisitor($visitor)); + + return $this; } /** @param string[]|null $engines */ @@ -296,9 +304,11 @@ public function applyTo(RuntimeConfiguration $runtimeConfiguration): void } } - public function sync(): void + public function sync(): static { $this->configuredInstrumenter(); + + return $this; } public function instrumenting(): bool From 709e916ab740f4f408e6014b36ddc3647bd26318 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 13 Oct 2026 18:13:15 -0500 Subject: [PATCH 10/19] Add HTML analyzer and parser tests --- tests/Antlers/Parser/CharacterOffsetsTest.php | 13 + tests/Antlers/Parser/Html/DocumentTest.php | 458 ++++++++++++++++++ .../Parser/Html/TreeConstructionTest.php | 376 ++++++++++++++ tests/Antlers/Parser/ParserErrorsTest.php | 10 + tests/Antlers/Parser/PathParserTest.php | 19 + 5 files changed, 876 insertions(+) create mode 100644 tests/Antlers/Parser/Html/DocumentTest.php create mode 100644 tests/Antlers/Parser/Html/TreeConstructionTest.php diff --git a/tests/Antlers/Parser/CharacterOffsetsTest.php b/tests/Antlers/Parser/CharacterOffsetsTest.php index 4fdc0eb4013..e74cf53c140 100644 --- a/tests/Antlers/Parser/CharacterOffsetsTest.php +++ b/tests/Antlers/Parser/CharacterOffsetsTest.php @@ -73,6 +73,19 @@ public function test_byte_offsets_can_be_counted_from_a_known_anchor() $this->assertSame([10 => 41], CharacterOffsets::toCharacters(self::MIXED, [10], true, 6, 40)); } + public function test_normalized_offsets_map_back_to_source_bytes_and_characters() + { + [$bytes, $characters] = CharacterOffsets::normalizedToBytesAndCharacters("a\r\nb\u{00E9}\r\nc", [0, 1, 2, 3, 4, 5, 6]); + + $this->assertSame([0 => 0, 1 => 1, 2 => 3, 3 => 4, 4 => 6, 5 => 8, 6 => 9], $bytes); + $this->assertSame([0 => 0, 1 => 1, 3 => 3, 4 => 4, 6 => 5, 8 => 7, 9 => 8], $characters); + + [$bytes, $characters] = CharacterOffsets::normalizedToBytesAndCharacters('plain', [0, 3, 9]); + + $this->assertSame([0 => 0, 3 => 3, 9 => 5], $bytes); + $this->assertSame([0 => 0, 3 => 3, 5 => 5], $characters); + } + public function test_conversions_round_trip_on_mixed_content() { $source = str_repeat("plain ascii line\n", 40).str_repeat("\u{65E5}\u{672C}\u{8A9E} \u{1F389} caf\u{00E9}\n", 40); diff --git a/tests/Antlers/Parser/Html/DocumentTest.php b/tests/Antlers/Parser/Html/DocumentTest.php new file mode 100644 index 00000000000..104d5bf4437 --- /dev/null +++ b/tests/Antlers/Parser/Html/DocumentTest.php @@ -0,0 +1,458 @@ +parseHtml('

before {{ title }} {{ value }} after

'); + [$title, $value] = $nodes; + + $this->assertSame('p', $title->parentElement()->name()); + $this->assertSame('em', $value->parentElement()->name()); + $this->assertInstanceOf(HtmlAntlersNode::class, $title->htmlNode()); + $this->assertSame($title->htmlNode(), $parser->html()->node($title)); + $this->assertSame($title->htmlNode(), $title->htmlNode()); + $this->assertSame('before ', $title->previousHtmlSibling()->toHtml()); + $this->assertSame(' ', $title->nextHtmlSibling()->toHtml()); + } + + public function test_the_graph_recovers_useful_parents_from_bogus_nesting_and_round_trips_source() + { + $template = '

{{ one }}

{{ two }}{{ three }}
'; + [$parser, $nodes] = $this->parseHtml($template); + + $this->assertSame('p', $nodes[0]->parentElement()->name()); + $this->assertSame('div', $nodes[1]->parentElement()->name()); + $this->assertSame('span', $nodes[2]->parentElement()->name()); + $this->assertSame($template, $parser->html()->markDirty()->toHtml()); + } + + #[DataProvider('fosteredFormattingProvider')] + public function test_text_inside_formatting_reconstructed_out_of_table_sections_round_trips($template) + { + [$parser] = $this->parseHtml($template); + + $this->assertSame($template, $parser->html()->markDirty()->toHtml()); + } + + public static function fosteredFormattingProvider() + { + return [ + 'colgroup' => ['x'], + 'colgroup with cell' => ['
x
y'], + 'closed formatting' => ['x
'], + 'text before the colgroup' => ['xy'], + 'caption' => ['
'], + 'closed table' => ['
x
x
'], + 'anchor after nested table' => ['x