From 0fa6606fbab96e80a5f0337f4c42610409286699 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 10 Sep 2026 11:05:13 -0500 Subject: [PATCH 01/37] =?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: Sat, 12 Sep 2026 14:07:00 -0500 Subject: [PATCH 02/37] Field tracing --- src/Fields/Field.php | 28 +++ src/Fields/Value.php | 51 ++++- src/Fieldtypes/Bard/Augmentor.php | 39 +++- src/Fieldtypes/Replicator.php | 15 +- .../Language/Runtime/NodeProcessor.php | 7 + src/View/Instrumentation/FieldDomTracer.php | 123 +++++++++++ src/View/Instrumentation/FieldMarkers.php | 118 +++++++++++ src/View/Instrumentation/FieldTracer.php | 199 ++++++++++++++++++ 8 files changed, 574 insertions(+), 6 deletions(-) create mode 100644 src/View/Instrumentation/FieldDomTracer.php create mode 100644 src/View/Instrumentation/FieldMarkers.php create mode 100644 src/View/Instrumentation/FieldTracer.php diff --git a/src/Fields/Field.php b/src/Fields/Field.php index ea36ea2236e..e727b679a22 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -28,6 +28,7 @@ class Field implements Arrayable protected $parent; protected $parentField; protected $parentIndex; + protected ?array $sourcePath = null; protected $validationContext; protected ?Form $form = null; @@ -42,6 +43,7 @@ public function newInstance() return (new static($this->handle, $this->config)) ->setParent($this->parent) ->setParentField($this->parentField, $this->parentIndex) + ->setSourcePath($this->sourcePath) ->setValue($this->value); } @@ -84,6 +86,32 @@ public function fieldPathPrefix() return implode('.', $this->fieldPathKeys()); } + /** @internal */ + public function sourcePathKeys(): array + { + if ($this->sourcePath !== null) { + return $this->sourcePath; + } + + $path = $this->parentField ? $this->parentField->sourcePathKeys() : []; + + if (isset($this->parentIndex)) { + $path[] = $this->parentIndex; + } + + $path[] = $this->handle(); + + return $path; + } + + /** @internal */ + public function setSourcePath(?array $path) + { + $this->sourcePath = $path; + + return $this; + } + public function setPrefix($prefix) { $this->prefix = $prefix; diff --git a/src/Fields/Value.php b/src/Fields/Value.php index 6a93a8b3cb5..e7fbef0a715 100644 --- a/src/Fields/Value.php +++ b/src/Fields/Value.php @@ -20,6 +20,8 @@ */ class Value implements ArrayAccess, IteratorAggregate, JsonSerializable { + private static ?\Closure $readObserver = null; + protected ?Field $sourceField = null; private $resolver; protected $raw; protected $handle; @@ -49,6 +51,10 @@ public function raw() { $this->resolve(); + if (self::$readObserver !== null) { + (self::$readObserver)($this); + } + return $this->raw; } @@ -64,6 +70,7 @@ public function resolve() if ($value instanceof Value) { $this->fieldtype = $value->fieldtype(); + $this->sourceField ??= $value->sourceField; $this->raw = $value->raw(); } else { $this->raw = $value; @@ -76,6 +83,10 @@ public function value() { $this->resolve(); + if (self::$readObserver !== null) { + (self::$readObserver)($this); + } + $raw = $this->raw; if (! $this->fieldtype) { @@ -96,6 +107,41 @@ private function getAugmentedValue($raw) : $this->fieldtype->augment($raw); } + /** @internal */ + public static function isObservingReads(): bool + { + return self::$readObserver !== null; + } + + /** @internal */ + public static function withReadObserver(\Closure $observer, callable $callback): mixed + { + $previous = self::$readObserver; + self::$readObserver = $observer; + + try { + return $callback(); + } finally { + self::$readObserver = $previous; + } + } + + /** @internal */ + public function sourceField(): ?Field + { + $this->resolve(); + + return $this->sourceField ?? $this->fieldtype?->field(); + } + + /** @internal */ + public function setSourceField(Field $field) + { + $this->sourceField = $field; + + return $this; + } + private function iteratorValue() { $value = $this->value(); @@ -223,7 +269,10 @@ public function shallow() { $this->resolve(); - return new static($this->raw, $this->handle, $this->fieldtype, $this->augmentable, true); + $value = new static($this->raw, $this->handle, $this->fieldtype, $this->augmentable, true); + $value->sourceField = $this->sourceField; + + return $value; } public function isRelationship(): bool diff --git a/src/Fieldtypes/Bard/Augmentor.php b/src/Fieldtypes/Bard/Augmentor.php index b1dc686c889..fcb9051f394 100644 --- a/src/Fieldtypes/Bard/Augmentor.php +++ b/src/Fieldtypes/Bard/Augmentor.php @@ -16,6 +16,8 @@ class Augmentor public static $currentBardConfig = []; protected $fieldtype; protected $sets = []; + protected $setSourceIndexes = []; + protected $trackFieldSources = false; protected $includeDisabledSets = false; protected $augmentSets = true; protected $withStatamicImageUrls = false; @@ -36,6 +38,10 @@ public function withStatamicImageUrls() public function augment($value, $shallow = false) { + $this->trackFieldSources = Value::isObservingReads(); + $this->sets = []; + $this->setSourceIndexes = []; + $hasSets = (bool) $this->fieldtype->config('sets'); if (! $value) { @@ -50,11 +56,18 @@ public function augment($value, $shallow = false) return $this->convertToHtml($value); } + if ($this->trackFieldSources) { + $value = $this->addSetIndexes($value); + } + if (! $this->includeDisabledSets) { $value = $this->removeDisabledSets($value); } - $value = $this->addSetIndexes($value); + if (! $this->trackFieldSources) { + $value = $this->addSetIndexes($value); + } + $value = $this->convertToHtml($value); $value = $this->convertToSets($value); @@ -130,8 +143,12 @@ protected function convertToSets($html) { $arr = preg_split('/(index-\d+<\/set>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - return collect($arr)->map(function ($html) { + return collect($arr)->map(function ($html, $index) { if (preg_match('/^index-(\d+)<\/set>/', $html, $matches)) { + if ($this->trackFieldSources) { + $this->setSourceIndexes[$index] = (int) $matches[1]; + } + return $this->sets[$matches[1]]; } @@ -145,7 +162,13 @@ protected function textValue($value) 'antlers' => $this->fieldtype->config('antlers'), ])); - return new Value($value, 'text', $fieldtype); + $value = new Value($value, 'text', $fieldtype); + + if ($this->trackFieldSources) { + $value->setSourceField($this->fieldtype->field()); + } + + return $value; } protected function augmentSets($value, $shallow) @@ -157,7 +180,15 @@ protected function augmentSets($value, $shallow) return $set; } - $values = $this->fieldtype->fields($set['type'], $index)->addValues($set)->{$augmentMethod}()->values()->all(); + $fields = $this->fieldtype->fields($set['type'], $index)->addValues($set); + + if ($this->trackFieldSources) { + $path = [...$this->fieldtype->field()->sourcePathKeys(), $this->setSourceIndexes[$index], 'attrs', 'values']; + + $fields->all()->each(fn ($field) => $field->setSourcePath([...$path, $field->handle()])); + } + + $values = $fields->{$augmentMethod}()->values()->all(); return array_merge($values, [RowId::handle() => $set[RowId::handle()] ?? null, 'type' => $set['type']]); })->all(); diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index 73e0bc0f48b..9f7446e972c 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -10,6 +10,7 @@ use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; +use Statamic\Fields\Value; use Statamic\Fields\Values; use Statamic\GraphQL\Types\ReplicatorSetsType; use Statamic\GraphQL\Types\ReplicatorSetType; @@ -148,7 +149,19 @@ public function fields($set, $index = -1) $config = Arr::get($this->flattenedSetsConfig(), "$set.fields"); $parent = $this->field->parent(); $locale = $parent instanceof Localization ? $parent->locale() : null; - $hash = md5($this->field->fieldPathPrefix().$index.json_encode($config).$locale); + + if (Value::isObservingReads()) { + $hash = 'field-trace:'.md5(json_encode([ + spl_object_id(is_object($parent) ? $parent : $this->field), + $this->field->fieldPathKeys(), + $this->field->sourcePathKeys(), + $index, + $config, + $locale, + ])); + } else { + $hash = md5($this->field->fieldPathPrefix().$index.json_encode($config).$locale); + } return Blink::once($hash, function () use ($config, $index) { return new Fields( diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index aadb4f4c869..9a7f036f9dc 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\FieldDomTracer; use Statamic\View\Instrumentation\InstrumentationState; use Statamic\View\Slot; use Statamic\View\State\CachesOutput; @@ -1248,6 +1249,12 @@ protected function reduceNodes($processNodes) $this->activeNode = $node; + if ($node instanceof AntlersNode && $node->name?->name === '___internal_field') { + $buffer .= FieldDomTracer::execute((int) $node->name->methodPart); + + continue; + } + if ($this->isTracingEnabled()) { $this->runtimeConfiguration->traceManager->traceOnEnter( $node, diff --git a/src/View/Instrumentation/FieldDomTracer.php b/src/View/Instrumentation/FieldDomTracer.php new file mode 100644 index 00000000000..eaa776ac187 --- /dev/null +++ b/src/View/Instrumentation/FieldDomTracer.php @@ -0,0 +1,123 @@ +stack = []; + $this->elements = []; + $this->markers = []; + $this->nextMarker = 0; + + try { + return parent::capture($render); + } finally { + self::$active = $previous; + $this->stack = []; + $this->elements = []; + } + } + + public function instruction(string $action, ?int $target = null, bool $opaque = false): int + { + $this->instructions[] = [$action, $target, $opaque]; + + return count($this->instructions); + } + + public static function execute(int $instruction): string + { + return self::$active?->run($instruction) ?? ''; + } + + private function run(int $instruction): string + { + if (! isset($this->instructions[$instruction - 1])) { + return ''; + } + + [$action, $target, $opaque] = $this->instructions[$instruction - 1]; + + if ($action === 'element') { + $this->nextMarker++; + $this->elements[$instruction] = $this->nextMarker; + + return (string) $this->nextMarker; + } + + if ($action === 'start') { + if ($target === null) { + $this->nextMarker++; + $id = $this->nextMarker; + } else { + $id = $this->elements[$target] ?? null; + } + + $this->stack[] = [ + 'instruction' => $instruction, + 'id' => $id, + 'comment' => $target === null, + 'opaque' => $opaque, + ]; + + if ($target === null) { + return ''; + } + + return ''; + } + + $frame = end($this->stack); + if (! $frame || $frame['instruction'] !== $target) { + return ''; + } + + array_pop($this->stack); + + if ($frame['comment']) { + return ''; + } + + return ''; + } + + protected function isObservingReads(): bool + { + return $this->stack !== []; + } + + protected function fieldRead(int $field): void + { + $frame = end($this->stack); + if ($frame && $frame['id'] !== null) { + $this->markers[$frame['id']][$field] = $field; + } + } + + public function markers(): array + { + return array_map('array_values', $this->markers); + } + + public function suppressesNestedMarkers(): bool + { + return end($this->stack)['opaque'] ?? false; + } +} diff --git a/src/View/Instrumentation/FieldMarkers.php b/src/View/Instrumentation/FieldMarkers.php new file mode 100644 index 00000000000..0e7d7453665 --- /dev/null +++ b/src/View/Instrumentation/FieldMarkers.php @@ -0,0 +1,118 @@ +tracer = $tracer; + + $this->attributeLayers['data-statamic-field'] = fn () => null; + + return $this; + } + + private function isOpaque($region) + { + if ( + $region->isPair() && + in_array( + $region->raw()->name?->name, + ['markdown', 'section', 'push', 'scope'], true + ) + ) { + return true; + } + + return false; + } + + protected function emitInstrumentation($template, array $analysis) + { + $edits = []; + $elements = []; + $opaqueUntil = -1; + + foreach ($analysis['regions'] as $region) { + if ($region->byteStart < $opaqueUntil) { + continue; + } + + $opaque = $this->isOpaque($region); + if ($opaque) { + $opaqueUntil = $region->byteEnd; + } + + $element = $region->context()->elementStartOffset; + $elementSafe = $region->context()->canInstrumentElement() && isset($analysis['elements'][$element]); + + if (! $region->commentSafe && ! $elementSafe) { + continue; + } + + $anchor = null; + if (! $region->commentSafe) { + if (! isset($elements[$element])) { + $facts = $analysis['elements'][$element]; + if (($facts['hasAttribute'])('data-statamic-field')) { + continue; + } + + $elements[$element] = $this->tracer->instruction('element'); + $edits[] = [ + 'start' => $facts['insertAt'], + 'remove' => 0, + 'content' => ' data-statamic-field="'.$this->helper($elements[$element]).'"', + 'type' => 'attribute', + 'nodeStart' => $facts['insertAt'], + 'attributeName' => 'data-statamic-field', + ]; + } + + $anchor = $elements[$element]; + } + + $start = $this->tracer->instruction('start', $anchor, $opaque); + $end = $this->tracer->instruction('end', $start); + $edits[] = [ + 'start' => $region->byteStart, + 'remove' => 0, + 'content' => $this->helper($start), + 'type' => 'start', + 'nodeStart' => $region->byteStart, + ]; + $edits[] = [ + 'start' => $region->byteEnd, + 'remove' => 0, + 'content' => $this->helper($end), + 'type' => 'end', + 'nodeStart' => $region->byteStart, + ]; + } + + if ($this->collectedEdits !== null) { + $this->collectedEdits = $edits; + + return $template; + } + + return $this->spliceByteEdits($template, $edits); + } + + private function helper(int $id): string + { + return '{{ ___internal_field:'.$id.' }}'; + } + + public function instrumentAntlers($template) + { + if ($this->tracer->suppressesNestedMarkers()) { + return $template; + } + + return parent::instrumentAntlers($template); + } +} diff --git a/src/View/Instrumentation/FieldTracer.php b/src/View/Instrumentation/FieldTracer.php new file mode 100644 index 00000000000..9317aed3517 --- /dev/null +++ b/src/View/Instrumentation/FieldTracer.php @@ -0,0 +1,199 @@ +capturing) { + throw new LogicException('A field trace capture is already active.'); + } + + $this->frames = []; + $this->fields = []; + $this->occurrences = []; + $this->fieldIds = []; + $this->values = new WeakMap; + $this->owners = new WeakMap; + $this->nextOwner = 0; + $this->nextOccurrence = 0; + $this->capturing = true; + + try { + return Value::withReadObserver($this->read(...), $render); + } finally { + $this->capturing = false; + $this->frames = []; + + $this->values = new WeakMap; + $this->owners = new WeakMap; + $this->fieldIds = []; + } + } + + public function fields(): array + { + return $this->fields; + } + + public function occurrences(): array + { + return $this->occurrences; + } + + public function onEnter(Span $span): mixed + { + if (! $this->capturing || ! $span->raw() instanceof AntlersNode) { + return null; + } + + $this->nextOccurrence++; + $id = $this->nextOccurrence; + $this->frames[$id] = [ + 'parent' => array_key_last($this->frames), + 'expression' => $span->expression, + 'view' => $span->view, + 'line' => $span->line, + 'fields' => [], + ]; + + return $id; + } + + private function read(Value $value): void + { + if ($this->readingMetadata || ! $this->isObservingReads()) { + return; + } + + if (! isset($this->values[$value])) { + $this->readingMetadata = true; + + try { + $this->values[$value] = $this->fieldId($value) ?? 0; + } finally { + $this->readingMetadata = false; + } + } + + $field = $this->values[$value]; + + if ($field !== 0) { + $this->fieldRead($field); + } + } + + protected function isObservingReads(): bool + { + return $this->frames !== []; + } + + protected function fieldRead(int $field): void + { + $this->frames[array_key_last($this->frames)]['fields'][$field] = $field; + } + + private function fieldId(Value $value): ?int + { + $field = $value->sourceField(); + + if (! $field) { + return null; + } + + $owner = $value->augmentable() ?? $field->parent(); + + $identity = is_object($owner) ? $owner : $field; + + if (! isset($this->owners[$identity])) { + $this->nextOwner++; + $reference = null; + $locale = null; + $blueprint = null; + + if (is_object($owner)) { + if (method_exists($owner, 'reference')) { + $reference = $owner->reference(); + } + + if (method_exists($owner, 'locale')) { + $locale = $owner->locale(); + } + + if (method_exists($owner, 'blueprint')) { + $blueprint = $owner->blueprint()?->fullyQualifiedHandle(); + } + } + + $this->owners[$identity] = [ + 'id' => $this->nextOwner, + 'reference' => $reference, + 'locale' => $locale, + 'blueprint' => $blueprint, + ]; + } + + $owner = $this->owners[$identity]; + $path = $field->sourcePathKeys(); + $reference = $owner['reference']; + $source = $owner['id']; + + if (is_string($reference) && $reference !== '' && ! str_ends_with($reference, '::')) { + $source = [$reference, $owner['locale'], $owner['blueprint']]; + } + + $key = json_encode([$source, $path, $field->type()]); + + if (isset($this->fieldIds[$key])) { + return $this->fieldIds[$key]; + } + + $id = count($this->fields) + 1; + $this->fields[$id] = [ + 'reference' => $owner['reference'], + 'locale' => $owner['locale'], + 'blueprint' => $owner['blueprint'], + 'path' => $path, + 'handle' => $field->handle(), + 'display' => $field->display(), + 'type' => $field->type(), + ]; + + $this->fieldIds[$key] = $id; + + return $id; + } + + public function onExit(Span $span, mixed $handle, mixed $output): void + { + if ($handle === null || ! isset($this->frames[$handle])) { + return; + } + + $frame = $this->frames[$handle]; + $frame['fields'] = array_values($frame['fields']); + $this->occurrences[$handle] = $frame; + unset($this->frames[$handle]); + } + + public function onRenderComplete(): void + { + } +} From 02895df2176b9b88ccf61e62134d5a8e1c1fe2c7 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 12 Sep 2026 14:07:31 -0500 Subject: [PATCH 03/37] Live preview marker injection, field sync and highlights. --- .../components/fieldtypes/GroupFieldtype.vue | 12 +- .../js/components/ui/LivePreview/FieldSync.js | 359 ++++++++++++++++++ .../components/ui/LivePreview/LivePreview.vue | 67 +++- .../ui/LivePreview/ManagesIframes.js | 69 +++- .../js/components/ui/LivePreview/Popout.vue | 22 +- resources/js/components/ui/Publish/Field.vue | 2 +- src/Tokens/Handlers/LivePreview.php | 46 ++- 7 files changed, 558 insertions(+), 19 deletions(-) create mode 100644 resources/js/components/ui/LivePreview/FieldSync.js diff --git a/resources/js/components/fieldtypes/GroupFieldtype.vue b/resources/js/components/fieldtypes/GroupFieldtype.vue index c784a00f7e6..94885b39684 100644 --- a/resources/js/components/fieldtypes/GroupFieldtype.vue +++ b/resources/js/components/fieldtypes/GroupFieldtype.vue @@ -9,7 +9,7 @@ @close="toggleFullscreen" > -
+
{ + selectFields(enabled); + channel.value?.postMessage({ event: 'field.selecting', enabled }); +}); const livePreviewFieldsPortal = computed(() => { return `live-preview-fields-${name.value}`; @@ -105,7 +145,7 @@ const update = debounce(() => { token.value = response.data.token; const url = response.data.url; const tgt = toRaw(props.targets[target.value]); - const messagePayload = { token: token.value, reference: props.reference }; + const messagePayload = { token: token.value, selectingFields: selectingFields.value }; poppedOut.value ? channel.value.postMessage({ event: 'updated', url, target: tgt, payload: messagePayload }) : updateIframeContents(url, tgt, messagePayload, setIframeAttributes); @@ -181,6 +221,8 @@ watch( (enabled, wasEnabled) => { if (wasEnabled && !enabled) { teardownPayloadWatch(); + disposeIframe?.(); + selectingFields.value = false; nextTick(() => (portalEnabled.value = false)); } else { portalEnabled.value = enabled; @@ -202,9 +244,12 @@ const canPopOut = computed(() => typeof BroadcastChannel === 'function'); function popout() { poppedOut.value = true; - channel.value = channel.value || new BroadcastChannel('livepreview'); + channel.value = channel.value || new BroadcastChannel(channelName); channel.value.onmessage = (e) => { switch (e.data.event) { + case 'field.selected': + selectField(e.data.field); + break; case 'popout.opened': listenForPopoutClose(); update(); @@ -223,12 +268,15 @@ function popout() { const width = iframeContentContainer.value.clientWidth; const height = iframeContentContainer.value.clientHeight; const left = screen.width - width; - popoutWindow.value = window.open(props.url, 'livepreview', `width=${width},height=${height},top=0,left=${left}`); + const popoutUrl = new URL(props.url, window.location.href); + popoutUrl.searchParams.set('preview-session', channelName); + popoutWindow.value = window.open(popoutUrl.href, channelName, `width=${width},height=${height},top=0,left=${left}`); } function listenForPopoutClose() { channel.value.postMessage({ event: 'ping' }); - setTimeout(() => { + clearTimeout(popoutTimer); + popoutTimer = setTimeout(() => { if (popoutResponded.value) { listenForPopoutClose(); } else { @@ -333,6 +381,9 @@ Statamic.$events.$on(refreshEvent, refreshHandler); onUnmounted(() => { teardownPayloadWatch(); + disposeIframe?.(); + clearTimeout(popoutTimer); + channel.value?.close(); keybinding.value.destroy(); Statamic.$events.$off(refreshEvent, refreshHandler); }); @@ -351,6 +402,10 @@ onUnmounted(() => { {{ __('Live Preview') }}
+ + @@ -383,7 +438,7 @@ onUnmounted(() => { class="live-preview-editor @container/live-preview" :style="{ width: poppedOut ? '100%' : `${editorWidth}px` }" > -
+
diff --git a/resources/js/components/ui/LivePreview/ManagesIframes.js b/resources/js/components/ui/LivePreview/ManagesIframes.js index 68592cf27ce..3b2d592ca31 100644 --- a/resources/js/components/ui/LivePreview/ManagesIframes.js +++ b/resources/js/components/ui/LivePreview/ManagesIframes.js @@ -1,7 +1,42 @@ import { ref } from 'vue'; +import { createFieldSync, fieldHighlightDuration } from './FieldSync.js'; -export function useIframeManager(iframeContentContainer) { +export function useIframeManager(iframeContentContainer, onSelectField = () => {}) { const previousUrl = ref(null); + let fieldSync; + let connectedIframe; + let selecting = false; + let selectedPath = null; + let highlightExpiresAt = 0; + let generation = 0; + + function connectFields() { + fieldSync?.dispose(); + fieldSync = null; + + try { + const doc = connectedIframe?.contentDocument; + if (!doc?.body || !doc.getElementById('statamic-preview-fields')) return; + + fieldSync = createFieldSync(doc, onSelectField); + fieldSync.select(selecting); + const remaining = highlightExpiresAt - Date.now(); + if (selectedPath && remaining > 0) fieldSync.highlight(selectedPath, remaining); + } catch { } + } + + function disconnectFields() { + connectedIframe?.removeEventListener('load', connectFields); + connectedIframe = null; + fieldSync?.dispose(); + fieldSync = null; + } + + function watchIframe(iframe) { + disconnectFields(); + connectedIframe = iframe; + iframe.addEventListener('load', connectFields); + } const hasIframeSourceChanged = (existingSrc, newSrc) => { existingSrc = new URL(existingSrc); @@ -27,6 +62,8 @@ export function useIframeManager(iframeContentContainer) { }; const updateIframeContents = async (url, target, payload, setIframeAttributes) => { + generation++; + const revision = generation; const iframe = document.createElement('iframe'); iframe.setAttribute('frameborder', '0'); iframe.setAttribute('src', url); @@ -34,11 +71,12 @@ export function useIframeManager(iframeContentContainer) { setIframeAttributes(iframe); const container = iframeContentContainer.value; - let iframeUrl = new URL(url); + let iframeUrl = new URL(url, window.location.href); let cleanUrl = iframeUrl.host + iframeUrl.pathname; // If there's no iframe yet, just append it. if (!container.firstChild) { + watchIframe(iframe); container.appendChild(iframe); previousUrl.value = cleanUrl; return; @@ -56,16 +94,22 @@ export function useIframeManager(iframeContentContainer) { if (Statamic.$config.get('livePreview.hot_reload_contents', false)) { const iframeWindow = container.firstChild.contentWindow; const iframeDocument = container.firstChild.contentDocument; + if (!iframeDocument) return; + const updatedHtml = await fetch(url).then((response) => response.text()); + if (revision !== generation) return; + const updatedDocument = new DOMParser().parseFromString(updatedHtml, 'text/html'); if (typeof iframeWindow.StatamicLivePreviewMorph !== 'undefined') { - iframeWindow.StatamicLivePreviewMorph(iframeDocument, updatedDocument); + await iframeWindow.StatamicLivePreviewMorph(iframeDocument, updatedDocument); + if (revision === generation) connectFields(); return; } if (typeof iframeWindow.Alpine !== 'undefined' && typeof iframeWindow.Alpine.morph !== 'undefined') { iframeWindow.Alpine.morph(iframeDocument.body, updatedDocument.body); + connectFields(); return; } @@ -75,18 +119,20 @@ export function useIframeManager(iframeContentContainer) { } iframeDocument.body.innerHTML = updatedDocument.body.innerHTML; + connectFields(); } return; } - let isSameOrigin = url.startsWith('/') || iframeUrl.host === window.location.host; + let isSameOrigin = iframeUrl.origin === window.location.origin; let preserveScroll = isSameOrigin && cleanUrl === previousUrl.value; let scroll = preserveScroll ? [container.firstChild.contentWindow.scrollX ?? 0, container.firstChild.contentWindow.scrollY ?? 0] : null; + watchIframe(iframe); container.replaceChild(iframe, container.firstChild); if (preserveScroll) { @@ -105,5 +151,18 @@ export function useIframeManager(iframeContentContainer) { return { previousUrl, updateIframeContents, - } + selectFields(enabled) { + selecting = enabled; + fieldSync?.select(enabled); + }, + highlightField(path) { + selectedPath = path; + highlightExpiresAt = Date.now() + fieldHighlightDuration; + fieldSync?.highlight(path); + }, + dispose() { + generation++; + disconnectFields(); + }, + }; } diff --git a/resources/js/components/ui/LivePreview/Popout.vue b/resources/js/components/ui/LivePreview/Popout.vue index aca3a197d89..f406d77e2e7 100644 --- a/resources/js/components/ui/LivePreview/Popout.vue +++ b/resources/js/components/ui/LivePreview/Popout.vue @@ -1,24 +1,35 @@