diff --git a/config/antlers.php b/config/antlers.php index b2cdd86f7bb..e2a2f084e7a 100644 --- a/config/antlers.php +++ b/config/antlers.php @@ -15,6 +15,41 @@ 'debugbar' => env('STATAMIC_ANTLERS_DEBUGBAR', true), + /* + |-------------------------------------------------------------------------- + | Tracing + |-------------------------------------------------------------------------- + | + | When tracing is enabled, every tracer listed here is resolved from the + | container and notified as every Antlers node is entered and exited. + | + */ + + 'tracing' => env('STATAMIC_ANTLERS_TRACING', false), + + 'tracers' => [], + + /* + |-------------------------------------------------------------------------- + | HTML Instrumentation + |-------------------------------------------------------------------------- + | + | When enabled, rendered Antlers regions are wrapped in HTML comments and + | attributes that point back to the templates they came from. Analysis + | is cached by template content; set "cache" to false to skip that. + | + */ + + 'instrumentation' => [ + 'enabled' => env('STATAMIC_ANTLERS_INSTRUMENTATION', false), + 'cache' => env('STATAMIC_ANTLERS_INSTRUMENTATION_CACHE', true), + 'comments' => true, + 'prefix' => 'antlers', + 'attributes' => [], + 'metadata' => [], + 'componentPrefixes' => [], + ], + /* |-------------------------------------------------------------------------- | Guarded Variables diff --git a/src/Contracts/View/Instrumentation/ComposesRenderedOutput.php b/src/Contracts/View/Instrumentation/ComposesRenderedOutput.php new file mode 100644 index 00000000000..0ba74b0dd1e --- /dev/null +++ b/src/Contracts/View/Instrumentation/ComposesRenderedOutput.php @@ -0,0 +1,7 @@ + [ + \Statamic\View\Instrumentation\RemoveMisplacedMarkersFromResponse::class, \Statamic\Listeners\ClearState::class, ], \Illuminate\Console\Events\CommandFinished::class => [ diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index fa7741f51c6..95a784d9010 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -28,6 +28,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 @@ -85,6 +86,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]); @@ -152,7 +155,7 @@ private function registerAntlers() } if ($this->profilerEnabled()) { - if (! $isTracingOn) { + if ($runtimeConfig->traceManager === null) { $runtimeConfig->traceManager = new TraceManager(); $runtimeConfig->isTracingEnabled = true; } @@ -160,6 +163,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/StaticCaching/Middleware/Cache.php b/src/StaticCaching/Middleware/Cache.php index 6a3d9ccc849..08e044699d0 100644 --- a/src/StaticCaching/Middleware/Cache.php +++ b/src/StaticCaching/Middleware/Cache.php @@ -22,6 +22,7 @@ use Statamic\StaticCaching\NoCache\Session; use Statamic\StaticCaching\Replacer; use Statamic\StaticCaching\ResponseStatus; +use Statamic\View\Antlers\Language\Facades\Runtime; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use function Statamic\trans as __; @@ -213,11 +214,19 @@ private function canBeCached($request) return false; } + if (Runtime::instrumenting()) { + return false; + } + return true; } private function shouldBeCached($request, $response) { + if (Runtime::instrumenting()) { + return false; + } + // Only GET requests should be cached. For instance, Live Preview hits frontend URLs as // POST requests to preview the changes. We don't want those to trigger any caching, // or else pending changes will be shown immediately, even without hitting save. diff --git a/src/StaticCaching/NoCache/StringFragment.php b/src/StaticCaching/NoCache/StringFragment.php index e6c1ebf7215..66211a7963f 100644 --- a/src/StaticCaching/NoCache/StringFragment.php +++ b/src/StaticCaching/NoCache/StringFragment.php @@ -5,6 +5,7 @@ use Statamic\Facades\File; use Statamic\Support\Arr; use Statamic\Support\Str; +use Statamic\View\Antlers\Language\Runtime\RuntimeParser; class StringFragment { @@ -32,6 +33,8 @@ public function render(): string $this->data['__frontmatter'] = Arr::pull($this->data, 'view', []); + $this->data[RuntimeParser::CAPTURED_CONTENT] = true; + $rendered = view('nocache::'.$view, $this->data)->render(); File::delete($path); diff --git a/src/StaticCaching/NoCache/Tags.php b/src/StaticCaching/NoCache/Tags.php index 8e71ae28f9b..295f940f451 100644 --- a/src/StaticCaching/NoCache/Tags.php +++ b/src/StaticCaching/NoCache/Tags.php @@ -2,10 +2,11 @@ namespace Statamic\StaticCaching\NoCache; +use Statamic\Contracts\View\Instrumentation\ComposesRenderedOutput; use Statamic\Facades\Antlers; use Statamic\StaticCaching\Middleware\Cache; -class Tags extends \Statamic\Tags\Tags +class Tags extends \Statamic\Tags\Tags implements ComposesRenderedOutput { public static $handle = 'nocache'; public static $stack = 0; diff --git a/src/Tags/Cache.php b/src/Tags/Cache.php index 06043ccc912..06c3ed3aa96 100644 --- a/src/Tags/Cache.php +++ b/src/Tags/Cache.php @@ -6,6 +6,7 @@ use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\View\Antlers\Language\Runtime\LiteralReplacementManager; +use Statamic\View\Antlers\Language\Runtime\RuntimeParser; use Statamic\View\Antlers\Language\Runtime\StackReplacementManager; use Statamic\View\State\CachesOutput; @@ -68,6 +69,10 @@ private function isEnabled() return false; } + if ($this->parser instanceof RuntimeParser && $this->parser->instrumenting()) { + return false; + } + return true; } diff --git a/src/Tags/ComponentProxy.php b/src/Tags/ComponentProxy.php index 3a83555ee8c..a04225bbfa6 100644 --- a/src/Tags/ComponentProxy.php +++ b/src/Tags/ComponentProxy.php @@ -9,6 +9,7 @@ use Illuminate\View\ComponentAttributeBag; use Illuminate\View\DynamicComponent; use ReflectionClass; +use Statamic\View\Instrumentation\InstrumentationState; use Throwable; class ComponentProxy extends Tags @@ -35,11 +36,15 @@ public function setParameters($parameters) 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(); @@ -93,22 +98,38 @@ public function index() ]); self::$componentStack[] = [$component, $contextData]; + $componentStackPushed = true; - echo $this->parse($contextData); + echo InstrumentationState::withoutInstrumentation( + fn () => $this->parse($contextData) + ); } - $result = $__env->renderComponent(); + $result = InstrumentationState::withinComponentView( + 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(); + } } } @@ -125,7 +146,9 @@ public function componentSlot() $__env->slot($slot, null, $this->escapeBoundAttributes($context)); - echo $this->parse($contextData); + echo InstrumentationState::withoutInstrumentation( + fn () => $this->parse($contextData) + ); $__env->endSlot(); } catch (Throwable $e) { diff --git a/src/Tags/Concerns/RendersViews.php b/src/Tags/Concerns/RendersViews.php index c9b4a20785c..2667005007d 100644 --- a/src/Tags/Concerns/RendersViews.php +++ b/src/Tags/Concerns/RendersViews.php @@ -3,9 +3,14 @@ namespace Statamic\Tags\Concerns; use Illuminate\Support\HtmlString; +use Statamic\View\Antlers\AntlersString; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\InstrumentationState; trait RendersViews { + protected ?string $instrumentedSlotContent = null; + protected function viewName($partial) { $partial = str_replace('/', '.', $partial); @@ -49,7 +54,11 @@ protected function shouldRender(): bool protected function getSlotContent(): HtmlString|string { - $content = trim($this->parse()); + $parsed = $this->parse(); + + [$content, $this->instrumentedSlotContent] = Runtime::splitMarkers( + $parsed instanceof AntlersString ? $parsed->instrumented() : (string) $parsed + ); if ($this->isAntlersBladeComponent()) { return new HtmlString($content); @@ -58,6 +67,24 @@ protected function getSlotContent(): HtmlString|string return $content; } + protected function withInstrumentedSlot(array $variables): array + { + $key = InstrumentationState::twinsKey(); + $outputs = $variables[$key] ?? []; + + unset($outputs['slot'], $variables[$key]); + + if ($this->instrumentedSlotContent !== null && is_string($variables['slot'] ?? null)) { + $outputs['slot'] = [$variables['slot'], $this->instrumentedSlotContent]; + } + + if ($outputs !== []) { + $variables[$key] = $outputs; + } + + return $variables; + } + /** * The {{ exists }} tag. * diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php index f5eeae912b2..da9cd5c7c58 100644 --- a/src/Tags/IncludeTag.php +++ b/src/Tags/IncludeTag.php @@ -83,7 +83,7 @@ protected function render($view) $cascade = Cascade::toArray(); - $scope = array_merge( + $scope = $this->withInstrumentedSlot(array_merge( $this->params->bool('cascade') ? $cascade : [], $data, $this->resolveSlots($parameters, $data, $isBlade), @@ -92,7 +92,7 @@ protected function render($view) '__frontmatter' => $data, ], $isBlade ? [self::CONTEXT_KEY => true] : [] - ); + )); $hadViews = array_key_exists('views', $cascade); $viewsState = $cascade['views'] ?? null; diff --git a/src/Tags/Partial.php b/src/Tags/Partial.php index 4c14d3a6dd4..6872b3fe932 100644 --- a/src/Tags/Partial.php +++ b/src/Tags/Partial.php @@ -25,10 +25,10 @@ protected function render($partial) $context = array_diff_key($this->context->all(), array_flip(IncludeTag::VIEW_DATA_KEYS)); - $variables = array_merge($context, $this->params->all(), [ + $variables = $this->withInstrumentedSlot(array_merge($context, $this->params->all(), [ '__frontmatter' => $this->params->all(), 'slot' => $this->isPair ? $this->getSlotContent() : null, - ]); + ])); return view($this->viewName($partial), $variables) ->withoutExtractions() diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index 606ac973cb0..70496a40ef0 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -6,6 +6,7 @@ use Statamic\Contracts\View\Antlers\Parser; use Statamic\View\Antlers\Language\Parser\IdentifierFinder; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Instrumentation\InstrumentationState; class Antlers { @@ -29,17 +30,23 @@ public function usingParser(Parser $parser, Closure $callback) public function parse($str, $variables = [], $trusted = false) { - $parser = $this->parser(); $previousState = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; try { - return $parser->parse($str, $variables); + return $this->parser()->parse($str, $variables); } finally { GlobalRuntimeState::$isEvaluatingUserData = $previousState; } } + public function parseWithoutInstrumentation($str, $variables = [], $trusted = false) + { + return InstrumentationState::withoutInstrumentation( + fn () => $this->parse($str, $variables, $trusted) + ); + } + /** * Iterate over an array and parse the string/template for each. * diff --git a/src/View/Antlers/AntlersLoop.php b/src/View/Antlers/AntlersLoop.php index 4af1a1b4bc9..33e1e1e5950 100644 --- a/src/View/Antlers/AntlersLoop.php +++ b/src/View/Antlers/AntlersLoop.php @@ -2,7 +2,9 @@ namespace Statamic\View\Antlers; +use Statamic\View\Antlers\Language\Facades\Runtime; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Instrumentation\InstrumentationState; class AntlersLoop extends AntlersString { @@ -12,6 +14,8 @@ class AntlersLoop extends AntlersString protected $supplement; protected $context; protected $trusted; + protected $suppressesInstrumentation; + protected $tagBody; public function __construct($parser, $string, $variables, $supplement, $context, $trusted = false) { @@ -21,15 +25,34 @@ public function __construct($parser, $string, $variables, $supplement, $context, $this->supplement = $supplement; $this->context = $context; $this->trusted = $trusted; + $this->suppressesInstrumentation = InstrumentationState::suppressed(); + $this->tagBody = InstrumentationState::activeTagBody(); } public function __toString() + { + return $this->render()[0]; + } + + public function instrumented() + { + return $this->render()[1]; + } + + /** @return array{0: string, 1: string} */ + private function render() { $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = ! $this->trusted; try { - return $this->renderLoopContent(); + $render = fn () => InstrumentationState::withTagBody($this->tagBody, fn () => $this->renderLoopContent()); + + if ($this->suppressesInstrumentation) { + return InstrumentationState::withoutInstrumentation($render); + } + + return $render(); } finally { GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; } @@ -39,8 +62,9 @@ private function renderLoopContent() { $total = count($this->variables); $i = 0; + $hasInstrumentation = false; - $contents = collect($this->variables)->reduce(function ($carry, $item) use (&$i, $total) { + $instrumented = collect($this->variables)->reduce(function ($carry, $item) use (&$i, &$hasInstrumentation, $total) { if ($this->supplement) { $item = array_merge($item, [ 'index' => $i, @@ -57,11 +81,23 @@ private function renderLoopContent() ->parse($this->string, array_merge($this->context, $item)) ->withoutExtractions(); + if ($parsed instanceof AntlersString && $parsed->hasInstrumentation()) { + $hasInstrumentation = true; + + return $carry.$parsed->instrumented(); + } + return $carry.$parsed; }, ''); - $string = new AntlersString($contents, $this->parser); + if ($this->injectExtractions) { + $instrumented = $this->parser->injectNoparse($instrumented); + } + + if (! $hasInstrumentation) { + return [$instrumented, $instrumented]; + } - return (string) ($this->injectExtractions ? $string : $string->withoutExtractions()); + return [Runtime::removeMarkers($instrumented), $instrumented]; } } diff --git a/src/View/Antlers/AntlersString.php b/src/View/Antlers/AntlersString.php index 3dc4aa7b7e3..21423c5b3c7 100644 --- a/src/View/Antlers/AntlersString.php +++ b/src/View/Antlers/AntlersString.php @@ -3,12 +3,15 @@ namespace Statamic\View\Antlers; use Statamic\Contracts\View\Antlers\Parser; +use Statamic\View\Instrumentation\InstrumentationManager; class AntlersString { protected $string; protected $parser; protected $injectExtractions = true; + protected $instrumentedString = null; + protected $instrumentation = null; public function __construct(string $string, Parser $parser) { @@ -16,6 +19,19 @@ public function __construct(string $string, Parser $parser) $this->parser = $parser; } + /** + * @internal + */ + public static function withInstrumentation(string $rendered, Parser $parser, InstrumentationManager $instrumentation): self + { + $instance = new self('', $parser); + $instance->string = null; + $instance->instrumentedString = $rendered; + $instance->instrumentation = $instrumentation; + + return $instance; + } + public function withoutExtractions() { $this->injectExtractions = false; @@ -23,10 +39,35 @@ public function withoutExtractions() return $this; } + /** @internal */ + public function hasInstrumentation(): bool + { + return $this->instrumentedString !== null; + } + + /** + * @internal + * + * @return string + */ + public function instrumented() + { + return $this->finish($this->instrumentedString ?? $this->string); + } + public function __toString() + { + if ($this->instrumentedString !== null) { + $this->string ??= $this->instrumentation->removeMarkers($this->instrumentedString); + } + + return $this->finish($this->string); + } + + protected function finish($string) { return $this->injectExtractions - ? $this->parser->injectNoparse($this->string) - : $this->string; + ? $this->parser->injectNoparse($string) + : $string; } } 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..f382e67cbb3 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/AntlersNode.php @@ -0,0 +1,71 @@ +node = $node; + $this->source = $source; + } + + public static function fromArena(Document $document, int $id): self + { + $arena = $document->arena(); + $node = new self($document, $arena->antlersNode[$id], $arena->value[$id]); + $node->arenaId = $id; + + return $node; + } + + public function antlersNode(): LanguageAntlersNode + { + return $this->node; + } + + public function source(?string $source = null): string|static + { + 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; + unset($this->document->arena()->sourceStart[$this->arenaId]); + } + $this->touchDocument(); + + return $this; + } + + protected function render(): string + { + if ($this->arenaId === null) { + return $this->source; + } + + return $this->document->valueMarkup($this->arenaId); + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Arena.php b/src/View/Antlers/Language/Analyzers/Html/Arena.php new file mode 100644 index 00000000000..dbf9aeb5fa8 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Arena.php @@ -0,0 +1,379 @@ + */ + public array $parents = [null]; + + /** @var array */ + public array $firstChildren = [null]; + + /** @var array */ + public array $lastChildren = [null]; + + /** @var array */ + public array $previousSiblings = [null]; + + /** @var array */ + public array $nextSiblings = [null]; + + /** @var array */ + public array $name = [0 => null]; + + /** @var string[] */ + public array $value = ['']; + + /** @var array */ + public array $closing = [0 => '']; + + /** @var array */ + public array $opaqueType = []; + + /** @var array */ + public array $antlersNode = []; + + /** @var array */ + public array $embedded = []; + + /** @var array */ + public array $embeddedOwner = []; + + /** @var array */ + public array $sourceAnchor = []; + + /** @var array */ + public array $anchorTarget = []; + + /** @var array */ + public array $sourceStart = []; + + /** @var array */ + public array $closingStart = []; + + public function allocate(int $kind): int + { + $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(?string $name, string $opening, bool $selfClosing = false, string $namespace = 'html', bool $synthetic = false): int + { + $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 = $this->allocate($flags); + $this->name[$id] = $name; + $this->value[$id] = $opening; + $this->closing[$id] = ''; + + return $id; + } + + public function text(string $value, ?int $sourceStart = null): int + { + $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; + + if ($sourceStart !== null) { + $this->sourceStart[$id] = $sourceStart; + } + + return $id; + } + + public function antlers(LanguageAntlersNode $node, string $source): int + { + $id = $this->allocate(self::ANTLERS); + $this->antlersNode[$id] = $node; + $this->value[$id] = $source; + + return $id; + } + + public function opaque(string $source, string $type): int + { + $id = $this->allocate(self::OPAQUE); + $this->value[$id] = $source; + $this->opaqueType[$id] = $type; + + return $id; + } + + public function anchor(int $target): int + { + $id = $this->allocate(self::SOURCE_ANCHOR); + $this->anchorTarget[$id] = $target; + $this->sourceAnchor[$target] = $id; + + return $id; + } + + public function kind(int $id): int + { + return $this->kinds[$id]; + } + + public function parent(int $id): ?int + { + return $this->parents[$id]; + } + + public function setParent(int $id, ?int $parent): void + { + $this->parents[$id] = $parent; + } + + public function firstChild(int $id): ?int + { + return $this->firstChildren[$id]; + } + + public function lastChild(int $id): ?int + { + return $this->lastChildren[$id]; + } + + public function previous(int $id): ?int + { + return $this->previousSiblings[$id]; + } + + public function next(int $id): ?int + { + return $this->nextSiblings[$id]; + } + + public function anchorExisting(int $target): int + { + $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(int $parent, int $node): int + { + $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(int $reference, int $node): int + { + $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(int $reference, int $node): int + { + $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(int $node): int + { + $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(int $parent): array + { + $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(int $parent, int $node): bool + { + return $this->parent($node) === $parent && ! isset($this->embeddedOwner[$node]); + } + + public function namespace(int $id): string + { + $type = $this->kind($id); + + if ($type & self::SVG) { + return 'svg'; + } + + if ($type & self::MATHML) { + return 'mathml'; + } + + return 'html'; + } + + protected function setFirstChild(int $id, ?int $child): void + { + $this->firstChildren[$id] = $child; + } + + protected function setLastChild(int $id, ?int $child): void + { + $this->lastChildren[$id] = $child; + } + + protected function setPrevious(int $id, ?int $previous): void + { + $this->previousSiblings[$id] = $previous; + } + + protected function setNext(int $id, ?int $next): void + { + $this->nextSiblings[$id] = $next; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php new file mode 100644 index 00000000000..a91aa354ae2 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AdoptsFormattingElements.php @@ -0,0 +1,433 @@ +activeFormattingIndex($name); + + if ($activeIndex === null) { + return false; + } + + $formatting = $this->activeFormatting[$activeIndex]; + $match = $this->openStackIndex($formatting); + + if ($match === false) { + $this->removeActiveFormattingAt($activeIndex); + + 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, true); + $this->setElementClosingMarkup($formatting, $closingMarkup, $start); + } + + $this->sliceOpenStack($match); + $this->removeActiveFormattingAt($activeIndex); + + return true; + } + + protected function adoptFormattingAroundBlocks(int $match, int $formatting, ?string $closingMarkup, ?int $start, ?int $end): bool + { + $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 = $this->openStackIndex($formatting); + + if ($match === false) { + $this->removeActiveFormattingAt($activeIndex); + break; + } + + $furthestIndex = $this->furthestFormattingBoundaryIndex($match); + + if ($furthestIndex === null) { + if (! $transformed) { + return false; + } + + $closingOwner = $formatting; + $this->sliceOpenStack($match); + $this->removeActiveFormattingAt($activeIndex); + break; + } + + $transformed = true; + $furthest = $this->stack[$furthestIndex]; + $commonAncestor = 0; + + if ($match !== 0) { + $commonAncestor = $this->stack[$match - 1]; + } + + $bookmark = $activeIndex; + $lastNode = $furthest; + $inner = 0; + + for ($index = $furthestIndex - 1; $index > $match; $index--) { + $inner++; + $node = $this->stack[$index]; + $nodeActiveIndex = $this->activeFormattingPosition($node); + + if ($inner > self::MAX_FORMATTING_INNER_RECONSTRUCTIONS && $nodeActiveIndex !== false) { + $this->removeActiveFormattingAt($nodeActiveIndex); + $nodeActiveIndex = false; + } + + if ($nodeActiveIndex === false) { + $this->removeOpenElementAt($index); + $furthestIndex--; + + continue; + } + + $copy = $this->reconstructedCopy($node); + $this->activeFormatting[$nodeActiveIndex] = $copy; + $this->replaceOpenElementAt($index, $copy); + + if ($lastNode === $furthest) { + $bookmark = $nodeActiveIndex + 1; + } + + $this->anchorArenaNodeForMove($lastNode); + $this->arena->append($copy, $lastNode); + $lastNode = $copy; + } + + $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); + } + + $newFormatting = $this->reconstructedCopy($formatting); + + $moving = []; + + for ($child = $this->arena->firstChild($furthest); $child !== null; $child = $this->arena->next($child)) { + if (($this->arena->kinds[$child] & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + $moving[] = $child; + } + } + + foreach ($moving as $child) { + $this->anchorArenaNodeForMove($child); + $this->arena->append($newFormatting, $child); + } + + $this->arena->append($furthest, $newFormatting); + $closingOwner = $newFormatting; + + $formattingActiveIndex = $this->activeFormattingPosition($formatting); + + if ($formattingActiveIndex !== false) { + $this->removeActiveFormattingAt($formattingActiveIndex); + + if ($formattingActiveIndex < $bookmark) { + $bookmark--; + } + } + + $this->insertActiveFormattingAt($bookmark, $newFormatting); + $formattingStackIndex = $this->openStackIndex($formatting); + + if ($formattingStackIndex !== false) { + $this->removeOpenElementAt($formattingStackIndex); + } + + $furthestStackIndex = $this->openStackIndex($furthest); + + if ($furthestStackIndex === false) { + $this->pushOpenElement($newFormatting); + } else { + $this->insertOpenElementAt($furthestStackIndex + 1, $newFormatting); + } + } + + if ($transformed) { + $this->insertionChains = []; + } + + 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, $start); + $this->attachEmbeddedRegions($closingOwner, $start, $end, true); + } + } + + return true; + } + + protected function reconstructActiveFormatting(): void + { + $count = count($this->activeFormatting); + + if ($count === 0 + || $this->activeFormatting[$count - 1] === null + || $this->openStackIndex($this->activeFormatting[$count - 1]) !== false) { + return; + } + + $index = $count - 1; + + while ($index > 0 + && $this->activeFormatting[$index - 1] !== null + && $this->openStackIndex($this->activeFormatting[$index - 1]) === false) { + $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->pushOpenElement($copy); + $this->activeFormatting[$index] = $copy; + } + } + + protected function activeFormattingIndex(string $name): ?int + { + 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(int $targetIndex, array $boundaries): bool + { + 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[$this->arena->name[$element] ?? ''])) { + return false; + } + } + + return false; + } + + protected function furthestFormattingBoundaryIndex(int $formattingIndex): ?int + { + for ($index = $formattingIndex + 1, $count = count($this->stack); $index < $count; $index++) { + $candidate = $this->stack[$index]; + $name = $this->arena->name[$candidate] ?? ''; + + if ($this->isFormattingBoundary($candidate, $name)) { + return $index; + } + } + + return null; + } + + protected function isFormattingBoundary(int $candidate, string $name): bool + { + if ($this->isForeignElement($candidate)) { + return $this->isForeignScopeBoundary($candidate); + } + + return isset(self::$formattingBreakStarts[$name]); + } + + protected function pushActiveFormattingElement(int $element): void + { + $signature = $this->formattingSignature($element); + + if (($this->activeSignatureCounts[$signature] ?? 0) >= 3) { + $equivalent = []; + + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + $candidate = $this->activeFormatting[$index]; + + if ($candidate === null) { + break; + } + + if ($this->formattingSignature($candidate) === $signature) { + $equivalent[] = $index; + + if (count($equivalent) === 3) { + break; + } + } + } + + if (count($equivalent) >= 3) { + $this->removeActiveFormattingAt($equivalent[count($equivalent) - 1]); + } + } + + $this->activeFormatting[] = $element; + $this->countActiveFormatting($element, 1); + } + + protected function pushActiveFormattingMarker(): void + { + $this->activeFormatting[] = null; + $this->activeSignatureCountStack[] = $this->activeSignatureCounts; + $this->activeSignatureCounts = []; + } + + protected function removeActiveFormattingAt(int $index): void + { + [$element] = array_splice($this->activeFormatting, $index, 1); + + if ($element !== null) { + $this->countActiveFormatting($element, -1); + } + } + + protected function insertActiveFormattingAt(int $index, int $element): void + { + array_splice($this->activeFormatting, $index, 0, [$element]); + $this->countActiveFormatting($element, 1); + } + + protected function countActiveFormatting(int $element, int $delta): void + { + $signature = $this->formattingSignature($element); + $count = ($this->activeSignatureCounts[$signature] ?? 0) + $delta; + + if ($count > 0) { + $this->activeSignatureCounts[$signature] = $count; + } else { + unset($this->activeSignatureCounts[$signature]); + } + } + + protected function formattingSignature(int $element): string + { + if (isset($this->formattingSignatures[$element])) { + return $this->formattingSignatures[$element]; + } + + $markup = $this->arena->value[$element]; + + if (strpos($markup, '{{') !== false) { + $attributes = $markup; + } else { + $attributes = $this->handle($element)->attributes(); + ksort($attributes); + $attributes = serialize($attributes); + } + + return $this->formattingSignatures[$element] = $this->arena->namespace($element) + .'|'.$this->arena->name[$element] + .'|'.$attributes; + } + + protected function clearActiveFormattingToLastMarker(): void + { + for ($index = count($this->activeFormatting) - 1; $index >= 0; $index--) { + if ($this->activeFormatting[$index] === null) { + $this->activeFormatting = array_slice($this->activeFormatting, 0, $index); + $this->activeSignatureCounts = array_pop($this->activeSignatureCountStack) ?? []; + + return; + } + } + } + + protected function reconstructedCopy(int $element): int + { + $copy = $this->arena->element( + $this->arena->name[$element], + $this->arena->value[$element], + false, + $this->arena->namespace($element), + true + ); + + if (isset($this->arena->sourceStart[$element])) { + $this->arena->sourceStart[$copy] = $this->arena->sourceStart[$element]; + } + + if (isset($this->formattingSignatures[$element])) { + $this->formattingSignatures[$copy] = $this->formattingSignatures[$element]; + } + + return $copy; + } +} diff --git a/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php new file mode 100644 index 00000000000..e0d4bc75fcf --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/AnchorsSourceNodes.php @@ -0,0 +1,176 @@ +arena->kinds); + + for ($parent = $this->arena->parent($node); $parent !== null; $parent = $this->arena->parent($parent)) { + if ($remaining-- < 0) { + throw new \LogicException('Cycle detected while resolving an HTML arena ancestor.'); + } + + if ($parent === $ancestor) { + return true; + } + } + + return false; + } + + protected function arenaNodeHasSourceAnchoredAncestor(int $node): bool + { + return $this->arenaNodeAncestorSourceAnchor($node) !== null; + } + + protected function arenaNodeAncestorSourceAnchor(int $node): ?int + { + $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( + int $node, + int $container, + ?int $sourceTable, + ?int $sourceTableTail, + ?int $previous = null, + ?int $sourcePositionAnchor = null + ): void { + $containerIsSynthetic = (bool) ($this->arena->kind($container) & Arena::SYNTHETIC); + $sourcePositionAnchor = $sourcePositionAnchor + ?? ($this->arena->sourceAnchor[$container] ?? null); + + if ($sourcePositionAnchor === null && $sourceTable === null && ! $containerIsSynthetic) { + return; + } + + $sourcePositionTarget = null; + + if ($sourcePositionAnchor !== null) { + $sourcePositionTarget = $this->arena->anchorTarget[$sourcePositionAnchor] ?? null; + } + + 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; + } + } + + 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); + } + + 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(int $node, int $sourceContainer): bool + { + $sourcePositionAnchor = $this->arena->sourceAnchor[$sourceContainer] ?? null; + + if ($sourcePositionAnchor === null) { + return false; + } + + $anchorParent = $this->arena->parent($sourcePositionAnchor); + + if ($anchorParent === null) { + return false; + } + + $sourceTail = $sourcePositionAnchor; + + for ($next = $this->arena->next($sourceTail); $next !== null; $next = $this->arena->next($next)) { + if (($this->arena->kind($next) & Arena::KIND_MASK) !== Arena::SOURCE_ANCHOR) { + break; + } + + $sourceTail = $next; + } + + if ($sourceTail === $sourcePositionAnchor) { + return false; + } + + $this->arena->insertAfter($sourceTail, $this->arena->anchor($node)); + + return true; + } + + protected function anchorArenaNodeForMove(int $node): void + { + if ($this->arena->parent($node) === null) { + return; + } + + if (! isset($this->arena->sourceAnchor[$node])) { + $this->arena->anchorExisting($node); + + return; + } + + $this->arena->detach($node); + } +} 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..26c6f7358ef --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Concerns/BuildsDocumentGraph.php @@ -0,0 +1,1335 @@ +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(int $start, ?string $next): bool + { + 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(int $start, ?string $next): bool + { + if ($next === null || HtmlSpec::isAsciiAlpha($next)) { + return false; + } + + if (in_array($next, ['!', '?', '/'], true)) { + return false; + } + + return ! isset($this->regionEnds[$start + 1]); + } + + protected function endTagContext(string $name): array + { + $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(string $name): bool + { + if ($this->textElement === null) { + return false; + } + + return strcasecmp((string) $this->arena->name[$this->textElement], $name) === 0; + } + + protected function shouldCloseSelectBeforeEndTag(string $name): bool + { + if ($this->openSelect === null || ! $this->selectInTable) { + return false; + } + + if (! $this->isInHtmlSelectMode()) { + return false; + } + + return isset(self::$selectTableElements[$name]); + } + + protected function shouldIgnoreEndTagInSelect(string $name, bool $closesCurrentTextElement): bool + { + if ($this->openSelect === null || ! $this->isInHtmlSelectMode()) { + return false; + } + + if ($closesCurrentTextElement) { + return false; + } + + return ! in_array($name, ['option', 'optgroup', 'select', 'template'], true); + } + + protected function shouldCloseColgroupBeforeEndTag(string $name, bool $processAsHtml): bool + { + if (! $processAsHtml) { + return false; + } + + if ($this->tableMode() !== TableMode::ColumnGroup) { + return false; + } + + return ! in_array($name, ['col', 'colgroup', 'template'], true); + } + + protected function isHtmlFormEndTag(string $name, bool $processAsHtml): bool + { + if (! $processAsHtml || $name !== 'form') { + return false; + } + + return ! $this->hasOpenHtmlTemplate(); + } + + protected function consumeFormEndTag(int $start, int $end, string $markup): void + { + 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) { + $this->removeOpenElementAt($formIndex); + } + } + + $this->formElement = null; + $this->appendOpaqueMarkup($start, $end, $markup); + } + + protected function closeMatchedElement(int $match, string $name, string $markup, int $start, int $end): void + { + $matchedElement = $this->stack[$match]; + + if (! empty($this->regionStarts)) { + $this->attachEmbeddedRegions($matchedElement, $start, $end, true); + } + + $this->setElementClosingMarkup($matchedElement, $markup, $start); + + 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(int $start, int $end, string $markup): bool + { + 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, $start); + $this->sliceOpenStack($index); + + return true; + } + + return false; + } + + protected function consumeStartTagInSelectMode(?string $name, int $start, int $end, string $markup): bool + { + 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(int $container, ?string $name, string $markup): bool + { + if ($container === 0 || ! $this->isForeignElement($container)) { + return false; + } + + return $this->shouldExitForeignContent($name, $markup); + } + + protected function shouldIgnoreFrameStart(?string $name, bool $isHtmlElement, bool $processStartAsHtml): bool + { + if (! $isHtmlElement || ! $processStartAsHtml) { + return false; + } + + return in_array($name, ['frame', 'frameset'], true); + } + + protected function initialFosterState(?string $name, string $namespace, int $sourceContainer, bool $processStartAsHtml): bool + { + if ($processStartAsHtml && $name !== null) { + return $this->prepareTableStart($name); + } + + if ($sourceContainer !== 0 && $this->isForeignElement($sourceContainer)) { + return false; + } + + return $namespace !== 'html' && $this->shouldFosterContent(); + } + + protected function isFosteredHiddenInput(int $element, ?string $name, bool $foster): bool + { + if (! $foster || $name !== 'input') { + return false; + } + + return strtolower((string) $this->handle($element)->attr('type')) === 'hidden'; + } + + protected function insertStartElement( + int $element, + int $sourceContainer, + ?int $sourcePositionAnchor, + ?int $sourceTable, + ?int $sourceTableTail, + bool $foster + ): void { + 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( + int $element, + ?string $name, + string $namespace, + bool $selfClosing, + bool $selectInTable, + bool $tableForm + ): void { + if ($tableForm || ! $this->shouldPushElement($name, $namespace, $selfClosing)) { + return; + } + + $this->pushOpenElement($element); + + if ($namespace !== 'html') { + return; + } + + if ($name !== null && isset(self::$formattingElements[$name])) { + $this->pushActiveFormattingElement($element); + } + + if ($name === 'template') { + $this->templateInsertionModes[$element] = TableMode::Template; + } + + if ($name === 'select') { + $this->openSelect = $element; + $this->selectInTable = $selectInTable; + } + + if (isset(self::$formattingMarkerElements[$name ?? ''])) { + $this->pushActiveFormattingMarker(); + } + + if ($name === 'script') { + $this->scriptEscape = self::SCRIPT_DATA; + } + + if ($this->startsTextElement($name)) { + $this->textElement = $element; + } + } + + protected function build(?array $nodes): void + { + 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); + $position = $next; + + continue; + } + + $position = $this->consumeMarkup($position); + } + + $this->regionEnds = []; + $this->resetOpenStack(); + $this->activeFormatting = []; + $this->activeSignatureCounts = []; + $this->activeSignatureCountStack = []; + $this->formattingSignatures = []; + $this->templateInsertionModes = []; + $this->insertionChains = []; + } + + protected function consumeMarkup(int $start): int + { + $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('<', $start); + + 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(int $start, int $end, string $markup, ?string $name): int + { + $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; + $this->arena->sourceStart[$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(int $start, int $end, string $markup, string|false|null $name): int + { + 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)) { + $this->popOpenElement(); + } + + 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->pushOpenElement($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(int $start, int $end, string $markup): void + { + $sourceContainer = $this->currentContainer(); + [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); + $foster = $this->prepareTableStart('br'); + + if ($this->activeFormatting + && $this->characterTokenUsesHtmlRules($this->currentContainer())) { + $this->reconstructActiveFormatting(); + + if ($foster && $this->tableMode() === TableMode::Normal) { + $foster = false; + } + } + + $element = $this->arena->element('br', $markup, false, 'html'); + $this->elementSourceStarts[$element] = $start; + $this->arena->sourceStart[$element] = $start; + + $this->insertStartElement( + $element, + $sourceContainer, + $this->arena->sourceAnchor[$sourceContainer] ?? null, + $sourceTable, + $sourceTableTail, + $foster + ); + + if ($this->regionStarts) { + $this->attachEmbeddedRegions($element, $start, $end, true); + } + } + + protected function appendOpaqueMarkup(int $start, int $end, string $markup, ?string $forcedType = null): void + { + [$sourceTable, $sourceTableTail] = $this->sourceTablePosition(); + $type = $forcedType; + + if ($type === null) { + $type = $this->opaqueMarkupType($markup); + } + + $opaque = $this->arena->opaque($markup, $type); + $this->arena->sourceStart[$opaque] = $start; + $container = $this->currentContainer(); + $this->arena->append($container, $opaque); + $this->anchorRecoveredSourceNode($opaque, $container, $sourceTable, $sourceTableTail); + + 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(int $start, int $end, int $container): void + { + 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)); + $position = $next; + } + } + + protected function shouldCloseImpliedElementsBeforeStart(string $name, bool $tableForm, bool $startsInHtmlSelectMode): bool + { + 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(?string $name, bool $isHtmlElement): bool + { + if (! $isHtmlElement || $name !== 'form' || $this->formElement === null) { + return false; + } + + return ! $this->hasOpenHtmlTemplate(); + } + + protected function shouldIgnoreTableStructureStart(?string $name, bool $isHtmlElement): bool + { + if (! $isHtmlElement || ! isset(self::$tableStructureStarts[$name ?? ''])) { + return false; + } + + return $this->tableStructureMode() === null && ! $this->hasOpenHtmlTemplate(); + } + + protected function isTableSelectStart(?string $name, bool $isHtmlElement): bool + { + 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(?string $name, bool $isHtmlElement): bool + { + if (! $isHtmlElement || $name !== 'form') { + return false; + } + + return in_array($this->tableStructureMode(), [TableMode::Table, TableMode::TableBody, TableMode::Row], true); + } + + protected function prepareFormattingForStart(string $name, bool $foster, bool $tableForm, bool $startsInHtmlSelectMode): bool + { + 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 = $this->activeFormattingPosition($formatting); + + if ($activeIndex !== false) { + $this->removeActiveFormattingAt($activeIndex); + } + + $stackIndex = $this->openStackIndex($formatting); + + if ($stackIndex !== false) { + $this->removeOpenElementAt($stackIndex); + } + + 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(bool $foster, int $previousStackDepth): bool + { + if ($foster || count($this->stack) === $previousStackDepth) { + return false; + } + + return $this->shouldFosterContent(); + } + + protected function shouldReconstructFormattingBeforeStart(?string $name, bool $processStartAsHtml): bool + { + 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(?string $name, string $namespace): ?string + { + if ($namespace !== 'svg' || ! isset(self::$svgTagNames[$name ?? ''])) { + return $name; + } + + return self::$svgTagNames[$name]; + } + + protected function shouldPushElement(?string $name, string $namespace, bool $selfClosing): bool + { + if ($namespace !== 'html' + || $name === null + || ComponentPrefixes::matches($name, $this->componentPrefixes)) { + return ! $selfClosing; + } + + return ! isset(self::$voidElements[$name]); + } + + protected function startsTextElement(?string $name): bool + { + if ($name === null) { + return false; + } + + return isset(self::$rawTextElements[$name]) || $name === 'plaintext'; + } + + protected function opaqueMarkupType(string $markup): string + { + 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 startsPhpBlock(string $source, int $start): bool + { + return RegionExtents::startsPhpBlock($source, $start); + } + + protected function phpBlockEnd(int $start, int $length): int + { + for ($index = $start + 2; $index < $length; $index++) { + if (isset($this->regionEnds[$index])) { + $index = $this->regionEnds[$index] - 1; + + continue; + } + + if ($this->source[$index] === '?' && ($this->source[$index + 1] ?? null) === '>') { + return $index + 2; + } + } + + return $length; + } + + protected function bogusCommentEnd(int $start): int + { + $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(int $start, int $length): int + { + $state = self::TAG_STATE_NAME; + $quote = null; + $index = $start + 1; + + if (($this->source[$index] ?? null) === '/') { + $index++; + } + + $regionIndex = $this->regionIndexAtOrAfter($index); + $nextRegion = $this->regionStarts[$regionIndex] ?? PHP_INT_MAX; + + while ($index < $length) { + $limit = min($length, $nextRegion); + + if ($index < $limit) { + switch ($state) { + case self::TAG_STATE_QUOTED_VALUE: + $index += strcspn($this->source, $quote.'<', $index, $limit - $index); + break; + case self::TAG_STATE_UNQUOTED_VALUE: + $index += strcspn($this->source, "\t\n\f\r ><", $index, $limit - $index); + break; + case self::TAG_STATE_NAME: + $index += strcspn($this->source, "\t\n\f\r >/<", $index, $limit - $index); + break; + case self::TAG_STATE_ATTRIBUTE_NAME: + $index += strcspn($this->source, "\t\n\f\r =>/<", $index, $limit - $index); + break; + case self::TAG_STATE_BEFORE_VALUE: + case self::TAG_STATE_AFTER_ATTRIBUTE: + $index += strspn($this->source, "\t\n\f\r ", $index, $limit - $index); + break; + case self::TAG_STATE_BEFORE_ATTRIBUTE: + $index += strspn($this->source, "\t\n\f\r /", $index, $limit - $index); + break; + } + } + + if ($index >= $length) { + break; + } + + $embeddedEnd = null; + + if ($index === $nextRegion) { + $embeddedEnd = $this->regionEnds[$nextRegion]; + $regionIndex++; + $nextRegion = $this->regionStarts[$regionIndex] ?? PHP_INT_MAX; + } elseif ($this->source[$index] === '<' && $this->startsPhpBlock($this->source, $index)) { + $embeddedEnd = $this->phpBlockEnd($index, $length); + $regionIndex = $this->regionIndexAtOrAfter($embeddedEnd); + $nextRegion = $this->regionStarts[$regionIndex] ?? PHP_INT_MAX; + } + + if ($embeddedEnd !== null) { + if ($state === self::TAG_STATE_BEFORE_VALUE) { + $state = self::TAG_STATE_UNQUOTED_VALUE; + } elseif ($state === self::TAG_STATE_BEFORE_ATTRIBUTE || $state === self::TAG_STATE_AFTER_ATTRIBUTE) { + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + + $index = $embeddedEnd; + + continue; + } + + $char = $this->source[$index]; + $index++; + + if ($state === self::TAG_STATE_QUOTED_VALUE) { + if ($char === $quote) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + $quote = null; + } + + continue; + } + + if ($state === self::TAG_STATE_UNQUOTED_VALUE) { + if ($char === '>') { + return $index; + } + + if ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + } + + continue; + } + + if ($state === self::TAG_STATE_BEFORE_VALUE) { + if ($char === '"' || $char === "'") { + $state = self::TAG_STATE_QUOTED_VALUE; + $quote = $char; + } elseif ($char === '>') { + return $index; + } else { + $state = self::TAG_STATE_UNQUOTED_VALUE; + } + + continue; + } + + if ($state === self::TAG_STATE_NAME) { + if ($char === '>') { + return $index; + } + + if ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + } elseif ($char === '/') { + $state = self::TAG_STATE_SELF_CLOSING; + } + + continue; + } + + if ($state === self::TAG_STATE_ATTRIBUTE_NAME) { + if ($char === '=') { + $state = self::TAG_STATE_BEFORE_VALUE; + } elseif ($char === '>') { + return $index; + } elseif ($char === '/') { + $state = self::TAG_STATE_SELF_CLOSING; + } elseif ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_AFTER_ATTRIBUTE; + } + + continue; + } + + if ($state === self::TAG_STATE_AFTER_ATTRIBUTE) { + if ($char === '=') { + $state = self::TAG_STATE_BEFORE_VALUE; + } elseif ($char === '>') { + return $index; + } elseif ($char === '/') { + $state = self::TAG_STATE_SELF_CLOSING; + } else { + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + + continue; + } + + if ($state === self::TAG_STATE_SELF_CLOSING) { + if ($char === '>') { + return $index; + } + + if ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + } else { + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + + continue; + } + + if ($char === '>') { + return $index; + } + + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + + return $length; + } + + protected function nextRegionStart(int $position): ?int + { + $count = count($this->regionStarts); + + while ($this->regionCursor < $count + && $this->regionStarts[$this->regionCursor] < $position) { + $this->regionCursor++; + } + + return $this->regionStarts[$this->regionCursor] ?? null; + } + + protected function tagName(int $start, int $end, bool $closing): string|false|null + { + $index = $start + 1; + + if ($closing) { + $index++; + $index += strspn($this->source, "\t\n\f\r ", $index, max(0, $end - $index)); + } + + $name = ''; + $dynamic = false; + $regionIndex = $this->regionIndexAtOrAfter($index); + $nextRegion = $this->regionStarts[$regionIndex] ?? PHP_INT_MAX; + + while ($index < $end) { + $limit = min($end, $nextRegion); + $stop = $index + strcspn($this->source, "\t\n\f\r >/", $index, $limit - $index); + $name .= substr($this->source, $index, $stop - $index); + $index = $stop; + + if ($stop < $limit || $stop !== $nextRegion) { + break; + } + + if (! ($this->regionNodes[$nextRegion]->isComment ?? false)) { + $dynamic = true; + } + + $index = $this->regionEnds[$nextRegion]; + $regionIndex++; + $nextRegion = $this->regionStarts[$regionIndex] ?? PHP_INT_MAX; + } + + if ($name === '' && ! $dynamic) { + return false; + } + + if ($dynamic) { + return null; + } + + return strtolower(str_replace("\0", "\xEF\xBF\xBD", $name)); + } + + protected function isSelfClosingTag(int $start, int $end): bool + { + $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 = self::TAG_STATE_BEFORE_ATTRIBUTE; + $quote = null; + + for (; $index < $slash; $index++) { + $embeddedEnd = $this->regionEnds[$index] ?? null; + $char = $this->source[$index]; + + if ($embeddedEnd === null && $char === '<' && $this->startsPhpBlock($this->source, $index)) { + $embeddedEnd = $this->phpBlockEnd($index, $end); + } + + if ($embeddedEnd !== null) { + if ($state === self::TAG_STATE_BEFORE_VALUE) { + $state = self::TAG_STATE_UNQUOTED_VALUE; + } + + $index = $embeddedEnd - 1; + + continue; + } + + if ($state === self::TAG_STATE_QUOTED_VALUE) { + if ($char === $quote) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + $quote = null; + } + + continue; + } + + if ($state === self::TAG_STATE_UNQUOTED_VALUE) { + if ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_BEFORE_ATTRIBUTE; + } + + continue; + } + + if ($state === self::TAG_STATE_BEFORE_VALUE) { + if ($this->isHtmlSpace($char)) { + continue; + } + + if ($char === '"' || $char === "'") { + $state = self::TAG_STATE_QUOTED_VALUE; + $quote = $char; + } else { + $state = self::TAG_STATE_UNQUOTED_VALUE; + } + + continue; + } + + if ($state === self::TAG_STATE_ATTRIBUTE_NAME) { + if ($char === '=') { + $state = self::TAG_STATE_BEFORE_VALUE; + } elseif ($this->isHtmlSpace($char)) { + $state = self::TAG_STATE_AFTER_ATTRIBUTE; + } + + continue; + } + + if ($state === self::TAG_STATE_AFTER_ATTRIBUTE) { + if ($char === '=') { + $state = self::TAG_STATE_BEFORE_VALUE; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + + continue; + } + + if (! $this->isHtmlSpace($char)) { + $state = self::TAG_STATE_ATTRIBUTE_NAME; + } + } + + return ! in_array($state, [self::TAG_STATE_BEFORE_VALUE, self::TAG_STATE_QUOTED_VALUE, self::TAG_STATE_UNQUOTED_VALUE], true); + } + + protected function isHtmlSpace(string $char): bool + { + return HtmlSpec::isHtmlSpace($char); + } + + protected function resolvedEndOffset(int|false|null $offset, int $sourceLength, int $terminatorLength = 0): int + { + if ($offset === null || $offset === false) { + return $sourceLength; + } + + return $offset + $terminatorLength; + } + + protected function regionIndexAtOrAfter(int $position): int + { + $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): void + { + $characterRegions = []; + $needed = [0 => true]; + + foreach ($nodes as $node) { + if (! $node instanceof LanguageAntlersNode && ! $node instanceof PhpExecutionNode) { + continue; + } + + if ($node->startPosition === null || $node->endPosition === null) { + continue; + } + + $start = $node->startPosition->offset; + $end = $node->endPosition->offset + 1; + + if ($node instanceof PhpExecutionNode) { + $end = $node->endPosition->offset + strlen($node->rawEnd); + } + + $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): array + { + 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..64e234872e8 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Container.php @@ -0,0 +1,365 @@ +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(): array + { + return array_map([$this->document, 'handle'], $this->document->arena()->children($this->arenaContainerId())); + } + + /** @return Collection */ + public function descendants(?string $name = null): Collection + { + return $this->arenaDescendants($this->descendantNameFilter($name)); + } + + public function firstDescendant(?string $name = null): ?Element + { + return $this->arenaFirstDescendant($this->descendantNameFilter($name)); + } + + public function firstChild(): ?Node + { + $child = $this->document->arena()->firstChild($this->arenaContainerId()); + + return $this->containerChild($child); + } + + public function lastChild(): ?Node + { + $child = $this->document->arena()->lastChild($this->arenaContainerId()); + + return $this->containerChild($child); + } + + public function append(Node|string $content): static + { + $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(Node|string $content): static + { + $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): bool + { + if ($node->document() !== $this->document) { + return false; + } + + if ($node->arenaId() === null) { + return false; + } + + return $this->document->arena()->hasChild($this->arenaContainerId(), $node->arenaId()); + } + + public function insertBefore(Node $reference, Node|string $content): void + { + $this->insertArenaRelative($reference, $content, false); + } + + public function insertAfter(Node $reference, Node|string $content): void + { + $this->insertArenaRelative($reference, $content, true); + } + + public function replaceChild(Node $reference, Node|string $content): void + { + $this->replaceArenaChild($reference, $content); + } + + public function removeChild(Node $node): void + { + if (! $this->hasChild($node)) { + return; + } + + $this->releaseArenaSourceAnchors($node->arenaId()); + $this->document->arena()->detach($node->arenaId()); + $this->markDirty(); + } + + public function createElement(string $name): Element + { + return $this->document->createArenaElement($name); + } + + protected function renderChildren(): string + { + return $this->document->renderArenaChildren($this->arenaContainerId()); + } + + protected function normalizeArenaNode(Node|string $content): Node + { + 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): void + { + $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): void + { + $id = $node->arenaId(); + $arena = $this->document->arena(); + + if ($arena->parent($id) !== null && ! isset($arena->embeddedOwner[$id])) { + $this->releaseArenaSourceAnchors($id); + $arena->detach($id); + $this->document->markDirty(); + } + } + + protected function insertArenaRelative(Node $reference, Node|string $content, bool $after): void + { + 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, Node|string $content): void + { + 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(int $id): void + { + $arena = $this->document->arena(); + + if (($arena->kind($id) & Arena::KIND_MASK) === Arena::SOURCE_ANCHOR) { + $target = $arena->anchorTarget[$id] ?? null; + + if ($target !== null) { + unset($arena->sourceAnchor[$target]); + $arena->anchorTarget[$id] = null; + } + + return; + } + + $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)) { + $this->releaseArenaSourceAnchors($child); + } + } + + protected function arenaContainerId(): ?int + { + if ($this instanceof Document) { + return 0; + } + + return $this->arenaId; + } + + protected function descendantNameFilter(?string $name): array + { + if ($name === null) { + return []; + } + + return [(string) $name]; + } + + protected function containerChild(?int $child): ?Node + { + if ($child === null) { + return null; + } + + return $this->document->handle($child); + } + + protected function arenaDescendants(array $names): Collection + { + $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 Collection($elements); + } + + protected function arenaFirstDescendant(array $names): ?Element + { + $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(?string $name, array $set): bool + { + 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..de4bfdad5f6 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Document.php @@ -0,0 +1,654 @@ + */ + protected array $antlersNodes = []; + + /** @var array */ + protected array $handles = []; + + /** @var array */ + protected array $regionEnds = []; + + /** @var array */ + protected array $regionNodes = []; + + /** @var int[] */ + protected array $regionStarts = []; + + protected int $regionCursor = 0; + + /** @var int[] */ + protected array $stack = []; + + /** @var array */ + protected array $activeFormatting = []; + + /** @var array */ + protected array $activeSignatureCounts = []; + + /** @var array> */ + protected array $activeSignatureCountStack = []; + + /** @var array */ + protected array $formattingSignatures = []; + + protected int $scriptEscape = self::SCRIPT_DATA; + + protected ?int $openSelect = null; + + protected bool $selectInTable = false; + + protected ?int $formElement = null; + + protected ?int $textElement = null; + + protected ?int $rawTextClosingElement = null; + + protected int $rawTextClosingOffset = 0; + + /** @var array */ + protected array $openElementCounts = []; + + /** @var array */ + protected array $stackFacts = []; + + /** @var array, 1: int|null}> */ + protected array $insertionChains = []; + + protected int $tableStartDepth = 0; + + /** @var array */ + protected array $templateInsertionModes = []; + + /** @var array> */ + protected array $regionPlacements = []; + + /** @var array */ + protected array $elementSourceStarts = []; + + protected string $authoredSource; + + /** @var array|null */ + protected ?array $authoredPositions = null; + + protected ?bool $mapsAuthoredSource = null; + + /** @var int[] */ + protected array $collapsedLineEndings = []; + + protected int $collapsedCursor = 0; + + protected ?string $insertedLineEnding = null; + + protected array $componentPrefixes = []; + + private function __construct(string $source, ?string $authoredSource = null) + { + $this->document = $this; + $this->arena = new Arena(); + $this->source = $source; + $this->authoredSource = $authoredSource ?? $source; + } + + public function arena(): Arena + { + return $this->arena; + } + + /** @return array> */ + public function regionPlacements(): array + { + return $this->regionPlacements; + } + + public function elementSourceStart(?int $id): ?int + { + if ($id === null) { + return null; + } + + return $this->elementSourceStarts[$id] ?? null; + } + + public function elementSourceCharacterOffset(?int $id): ?int + { + return $this->authoredElementPosition($id)['offset'] ?? null; + } + + public function elementSourceLine(?int $id): ?int + { + return $this->authoredElementPosition($id)['line'] ?? null; + } + + /** @return array{offset: int, line: int}|null */ + protected function authoredElementPosition(?int $id): ?array + { + $byte = $this->elementSourceStart($id); + + if ($byte === null) { + return null; + } + + if ($this->authoredPositions === null) { + $this->resolveAuthoredPositions(); + } + + return $this->authoredPositions[$byte] ?? null; + } + + protected function resolveAuthoredPositions(): void + { + $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): void + { + $identity = spl_object_id($node); + + if (! isset($this->regionPlacements[$identity])) { + $this->regionPlacements[$identity] = $facts; + } + } + + /** @return array{0: array, 1: int|null} */ + protected function insertionChain(?int $id): array + { + if ($id === null || $id === 0) { + return [[], null]; + } + + if (isset($this->insertionChains[$id])) { + return $this->insertionChains[$id]; + } + + $pending = []; + + for ($ancestor = $id; $ancestor !== null && $ancestor !== 0 && ! isset($this->insertionChains[$ancestor]); $ancestor = $this->arena->parents[$ancestor]) { + $pending[] = $ancestor; + } + + [$chain, $innermost] = $ancestor === null || $ancestor === 0 ? [[], null] : $this->insertionChains[$ancestor]; + + for ($index = count($pending) - 1; $index >= 0; $index--) { + $node = $pending[$index]; + + if (($this->arena->kinds[$node] & Arena::KIND_MASK) === Arena::ELEMENT) { + if (count($chain) >= self::MAX_CHAIN_DEPTH) { + $chain = array_slice($chain, 0, self::MAX_CHAIN_DEPTH - 1); + } + + $chain[] = $this->arena->name[$node]; + + if (isset($this->elementSourceStarts[$node])) { + $innermost = $node; + } + } + } + + return $this->insertionChains[$id] = [$chain, $innermost]; + } + + public function handle(int $id): Node|static + { + 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 = []): self + { + $document = new self($parser->getParsedContent(), $parser->getOriginalContent()); + $document->componentPrefixes = $componentPrefixes; + $document->build($parser->getNodes()); + + return $document; + } + + public static function parse(string $template): self + { + if (NodeTypeAnalyzer::$environmentDetails === null) { + NodeTypeAnalyzer::$environmentDetails = app(EnvironmentDetails::class); + } + + $parser = new DocumentParser(); + $parser->parse($template); + + return $parser->html(); + } + + public static function isVoidElement(?string $name): bool + { + return is_string($name) && isset(self::$voidElements[strtolower($name)]); + } + + public function isComponentElementName(?string $name): bool + { + return $name !== null && ComponentPrefixes::matches($name, $this->componentPrefixes); + } + + public function createArenaElement(string $name): Element + { + 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(): ?Node + { + return null; + } + + public function node(LanguageAntlersNode $node): ?AntlersNode + { + $id = $this->antlersNodes[spl_object_id($node)] ?? null; + + if ($id === null) { + return null; + } + + return $this->handle($id); + } + + /** @return Collection */ + public function elements(string ...$names): Collection + { + return $this->arenaDescendants($names); + } + + /** @return LazyCollection */ + public function regions(?string $name = null): LazyCollection + { + return new LazyCollection(function () use ($name) { + foreach ($this->regionStarts as $start) { + $node = $this->regionNodes[$start]; + + if (! $node instanceof LanguageAntlersNode || ! $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(LanguageAntlersNode $node, ?string $name): bool + { + if ($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(): bool + { + return $this->dirty; + } + + public function markDirty(): static + { + $this->dirty = true; + + return $this; + } + + public function toHtml(): string + { + if (! $this->dirty) { + return $this->authoredSource; + } + + return $this->renderChildren(); + } + + protected function mapsAuthoredSource(): bool + { + if ($this->mapsAuthoredSource !== null) { + return $this->mapsAuthoredSource; + } + + if ($this->authoredSource === $this->source) { + return $this->mapsAuthoredSource = false; + } + + $collapsed = []; + $position = 0; + + while (($position = strpos($this->authoredSource, "\r\n", $position)) !== false) { + $collapsed[] = $position - count($collapsed); + $position += 2; + } + + if (strlen($this->authoredSource) - count($collapsed) !== strlen($this->source)) { + return $this->mapsAuthoredSource = false; + } + + $this->collapsedLineEndings = $collapsed; + $lineFeeds = substr_count($this->authoredSource, "\n"); + $carriageReturns = substr_count($this->authoredSource, "\r"); + + if ($collapsed !== [] && $lineFeeds === count($collapsed) && $carriageReturns === count($collapsed)) { + $this->insertedLineEnding = "\r\n"; + } elseif ($lineFeeds === 0 && $carriageReturns > 0) { + $this->insertedLineEnding = "\r"; + } + + return $this->mapsAuthoredSource = true; + } + + protected function authoredOffset(int $normalized): int + { + $count = count($this->collapsedLineEndings); + $cursor = $this->collapsedCursor; + + if ($cursor > 0 && $this->collapsedLineEndings[$cursor - 1] >= $normalized) { + $high = $cursor; + $low = 0; + } elseif ($cursor === $count || $this->collapsedLineEndings[$cursor] >= $normalized) { + return $normalized + $cursor; + } elseif ($cursor + 1 === $count || $this->collapsedLineEndings[$cursor + 1] >= $normalized) { + $this->collapsedCursor = $cursor + 1; + + return $normalized + $cursor + 1; + } else { + $low = $cursor + 1; + $high = $count; + } + + while ($low < $high) { + $middle = ($low + $high) >> 1; + + if ($this->collapsedLineEndings[$middle] < $normalized) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + $this->collapsedCursor = $low; + + return $normalized + $low; + } + + protected function sourceSlice(int $start, int $length): string + { + $from = $this->authoredOffset($start); + $to = $this->authoredOffset($start + $length); + + return substr($this->authoredSource, $from, $to - $from); + } + + public function adoptLineEndings(string $markup): string + { + if (! $this->mapsAuthoredSource() || $this->insertedLineEnding === null || ! str_contains($markup, "\n")) { + return $markup; + } + + if ($this->insertedLineEnding === "\r\n") { + return preg_replace('/(?arena->value[$id]; + + if (! str_contains($value, "\n") || ! $this->mapsAuthoredSource()) { + return $value; + } + + $start = $this->arena->sourceStart[$id] ?? null; + + if ($start === null) { + return $this->adoptLineEndings($value); + } + + return $this->sourceSlice($start, strlen($value)); + } + + public function closingMarkup(int $id): string + { + $closing = $this->arena->closing[$id] ?? ''; + + if (! str_contains($closing, "\n") || ! $this->mapsAuthoredSource()) { + return $closing; + } + + $start = $this->arena->closingStart[$id] ?? null; + + if ($start === null) { + return $this->adoptLineEndings($closing); + } + + return $this->sourceSlice($start, strlen($closing)); + } + + public function renderArenaChildren(int $parent): string + { + $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(int $id): string + { + $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 = $kind & Arena::SYNTHETIC ? '' : $this->valueMarkup($id); + + return $opening.$children.$this->closingMarkup($id); + } + + return $this->valueMarkup($id); + } + + public function __toString(): string + { + 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..5161434ea59 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Element.php @@ -0,0 +1,638 @@ + */ + protected array $attributes = []; + + /** @var array */ + protected array $attributeNames = []; + + /** @var array> */ + protected array $attributeSpans = []; + + /** @var array */ + protected array $changedAttributes = []; + + /** @var array */ + protected array $changedAttributeNames = []; + + /** @var array */ + protected array $removedAttributes = []; + + protected bool $attributesParsed = false; + + public function __construct(Document $document, ?string $name, string $openingTag, bool $selfClosing = false, string $namespace = 'html') + { + parent::__construct($document); + + $this->name = $name; + $this->namespace = $namespace; + $this->openingTag = $openingTag; + $this->selfClosing = $selfClosing; + } + + public static function fromArena(Document $document, int $id): self + { + $arena = $document->arena(); + $type = $arena->kind($id); + $element = new self( + $document, + $arena->name[$id], + $document->valueMarkup($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(): string + { + return $this->namespace; + } + + public function isHtml(): bool + { + 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(): LazyCollection + { + return LazyCollection::make(function () { + for ($element = $this->parentElement(); $element !== null; $element = $element->parentElement()) { + yield $element; + } + }); + } + + /** @return LazyCollection */ + public function ancestorsAndSelf(): LazyCollection + { + return LazyCollection::make(function () { + for ($element = $this; $element !== null; $element = $element->parentElement()) { + yield $element; + } + }); + } + + public function closingTag(): string + { + if ($this->arenaId !== null) { + return $this->document->closingMarkup($this->arenaId); + } + + return $this->closingTag; + } + + public function setClosingTag(string $tag): static + { + if (! $this->acceptsClosingTag()) { + throw new \LogicException('Reconstructed, void and self-closing HTML elements cannot have a closing tag.'); + } + + $this->closingTag = $tag; + + if ($this->arenaId !== null) { + $this->document->arena()->closing[$this->arenaId] = $tag; + unset($this->document->arena()->closingStart[$this->arenaId]); + } + + $this->touchDocument(); + + return $this; + } + + protected function acceptsClosingTag(): bool + { + if ($this->synthetic) { + return false; + } + + if (! $this->isHtml()) { + return ! $this->selfClosing; + } + + if (Document::isVoidElement($this->name)) { + return false; + } + + if (! $this->selfClosing) { + return true; + } + + return $this->name !== null && ! $this->document->isComponentElementName($this->name); + } + + public function isSelfClosing(): bool + { + return $this->selfClosing; + } + + public function isSynthetic(): bool + { + return $this->synthetic; + } + + public function is(string $name): bool + { + return $this->name !== null && strcasecmp($this->name, $name) === 0; + } + + /** @return string[] */ + public function classes(): array + { + return HtmlSpec::classList((string) $this->attr('class')); + } + + public function hasClass(string $class): bool + { + return in_array($class, $this->classes(), true); + } + + 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 = []; + + foreach ($this->attributes as $key => $value) { + if (isset($this->removedAttributes[$key]) || array_key_exists($key, $this->changedAttributes)) { + continue; + } + + $attributes[$this->attributeNames[$key] ?? $key] = $value; + } + + foreach ($this->changedAttributes as $key => $value) { + $attributes[$this->changedAttributeNames[$key] ?? $key] = $value; + } + + return $attributes; + } + + public function setAttribute(string $name, ?string $value = null): static + { + 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(string $name): static + { + 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(string $class): static + { + $value = (string) $this->attr('class'); + + 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)); + } + + protected function render(): string + { + if ($this->synthetic) { + return $this->renderChildren().$this->closingTag(); + } + + return $this->renderOpeningTag().$this->renderChildren().$this->closingTag(); + } + + protected function renderOpeningTag(): string + { + 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'] + $span['trailing'], '']; + } + } + + $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); + $whitespace = substr($this->openingTag, $span['start'], $span['whitespace']); + $edits[] = [$span['start'], $span['length'], $whitespace.$attribute]; + + foreach ($spans as $duplicate) { + $edits[] = [$duplicate['start'], $duplicate['length'] + $duplicate['trailing'], '']; + } + } 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); + } + + if (str_starts_with($opening, 'document->adoptLineEndings($opening); + } + + protected function parseAttributes(): void + { + $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 (($embeddedEnd = $this->embeddedEnd($index)) !== null) { + $index = $embeddedEnd; + + continue; + } + + $attributeStart = $index; + $dynamicName = false; + + if ($this->openingTag[$index] === '=') { + $index++; + } + + while ($index < $length + && ! HtmlSpec::isAttributeNameDelimiter($this->openingTag[$index])) { + if (($embeddedEnd = $this->embeddedEnd($index)) !== null) { + $dynamicName = true; + $index = $embeddedEnd; + + continue; + } + + $index++; + } + + $rawName = str_replace( + "\0", + "\xEF\xBF\xBD", + substr($this->openingTag, $attributeStart, $index - $attributeStart) + ); + $name = strtolower($rawName); + + $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 (($embeddedEnd = $this->embeddedEnd($index)) !== null) { + $index = $embeddedEnd; + + continue; + } + + $index++; + } + + $value = str_replace( + ["\0", "\r\n", "\r"], + ["\xEF\xBF\xBD", "\n", "\n"], + 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 (($embeddedEnd = $this->embeddedEnd($index)) !== null) { + $index = $embeddedEnd; + + 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->attributeNames[$name] = $this->isHtml() ? $name : $rawName; + } + + $trailingEnd = $attributeEnd; + + while ($trailingEnd < $length && $this->isHtmlSpace($this->openingTag[$trailingEnd])) { + $trailingEnd++; + } + + if ($trailingEnd < $length && $this->openingTag[$trailingEnd] !== '>') { + $trailingEnd = $attributeEnd; + } + + $this->attributeSpans[$name][] = [ + 'start' => $spanStart, + 'length' => $attributeEnd - $spanStart, + 'whitespace' => $attributeStart - $spanStart, + 'trailing' => $trailingEnd - $attributeEnd, + ]; + } + } + + protected function isHtmlSpace(string $char): bool + { + return HtmlSpec::isHtmlSpace($char); + } + + protected function ensureAttributesParsed(): void + { + if (! $this->attributesParsed) { + $this->attributesParsed = true; + $this->parseAttributes(); + } + } + + protected function openingNameEnd(): int + { + $length = strlen($this->openingTag); + $index = str_starts_with($this->openingTag, 'embeddedEnd($index)) !== null) { + $index = $embeddedEnd - 1; + + continue; + } + + $char = $this->openingTag[$index]; + + if (HtmlSpec::isTagNameDelimiter($char)) { + return $index; + } + } + + return $length; + } + + protected function embeddedEnd(int $start): ?int + { + $length = strlen($this->openingTag); + $end = RegionExtents::embeddedEndInBytes($this->openingTag, $start, $length); + + if ($end === false) { + return $length; + } + + return $end; + } + + protected function escapeAttributeValue(string $value): string + { + 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(string $source, int $start): int + { + $length = strlen($source); + $end = RegionExtents::endInBytes($source, $start, $length); + + if ($end === false) { + return $length; + } + + return $end; + } + + protected function markDirty(): void + { + $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..4b80f65c548 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Node.php @@ -0,0 +1,212 @@ +document = $document; + $this->arenaId = $arenaId; + } + + public function arenaId(): ?int + { + return $this->arenaId; + } + + public function document(): Document + { + return $this->document; + } + + public function parent(): Node|Document|null + { + 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(): ?Node + { + return $this->siblingAt(-1); + } + + public function nextSibling(): ?Node + { + return $this->siblingAt(1); + } + + 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(Node|string $content): static + { + $this->assertMutableSibling(); + $this->parent()->insertBefore($this, $content); + + return $this; + } + + public function after(Node|string $content): static + { + $this->assertMutableSibling(); + $this->parent()->insertAfter($this, $content); + + return $this; + } + + public function replaceWith(Node|string $content): static + { + $this->assertMutableSibling(); + $this->parent()->replaceChild($this, $content); + + return $this; + } + + public function wrapWith(Element $element): static + { + 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(): static + { + $this->assertMutableSibling(); + $this->parent()->removeChild($this); + + return $this; + } + + public function toHtml(): string + { + return $this->render(); + } + + public function __toString(): string + { + return $this->toHtml(); + } + + abstract protected function render(): string; + + protected function siblingAt(int $direction): ?Node + { + 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(?int $sibling): ?Node + { + if ($sibling === null) { + return null; + } + + return $this->document->handle($sibling); + } + + protected function assertMutableSibling(): void + { + 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(): void + { + $this->touchArenaNode($this->arenaId); + } + + protected function touchArenaNode(?int $arenaId): void + { + 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..7bf76ee076e --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/NodeSequence.php @@ -0,0 +1,174 @@ + */ + protected \Closure $iterate; + + protected function __construct(Document $document, \Closure $iterate) + { + $this->document = $document; + $this->iterate = $iterate; + } + + public static function childrenOf(Document $document, int $parent): self + { + 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): self + { + 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 $arena, int $parent, bool $reverse): ?int + { + if ($reverse) { + return $arena->lastChild($parent); + } + + return $arena->firstChild($parent); + } + + protected static function nextArenaSibling(Arena $arena, int $node, bool $reverse): ?int + { + if ($reverse) { + return $arena->previous($node); + } + + return $arena->next($node); + } + + protected static function regionTraversalStart(AntlersNode $opening, AntlersNode $closing, bool $reverse): array + { + if ($reverse) { + return [$opening, $closing->previousSibling()]; + } + + return [$closing, $opening->nextSibling()]; + } + + protected static function nextObjectSibling(Node $node, bool $reverse): ?Node + { + if ($reverse) { + return $node->previousSibling(); + } + + return $node->nextSibling(); + } + + public function document(): Document + { + return $this->document; + } + + /** @return LazyCollection */ + public function nodes(): LazyCollection + { + return LazyCollection::make(fn () => ($this->iterate)(false)); + } + + /** @return LazyCollection */ + public function elements(bool $deep = true): LazyCollection + { + 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): static + { + $this->rewriteEdge(TextEdge::LEADING, $rewrite, $descend); + $this->rewriteEdge(TextEdge::TRAILING, $rewrite, $descend); + + return $this; + } + + protected function rewriteEdge(string $edge, callable $rewrite, ?callable $descend = null): bool + { + $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 || $node instanceof SourceAnchor) { + 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..3385dc486e2 --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/OpaqueNode.php @@ -0,0 +1,64 @@ +source = $source; + $this->type = $type; + } + + public static function fromArena(Document $document, int $id): self + { + $arena = $document->arena(); + $node = new self($document, $arena->value[$id], $arena->opaqueType[$id]); + $node->arenaId = $id; + + return $node; + } + + public function type(): string + { + return $this->type; + } + + public function source(?string $source = null): string|static + { + if (func_num_args() === 0) { + return $this->source; + } + + $this->source = (string) $source; + + if ($this->arenaId !== null) { + $this->document->arena()->value[$this->arenaId] = $this->source; + unset($this->document->arena()->sourceStart[$this->arenaId]); + } + $this->touchDocument(); + + return $this; + } + + public function hasChild(Node $node): bool + { + return in_array($node->arenaId(), $this->document->arena()->embedded[$this->arenaId] ?? [], true); + } + + protected function render(): string + { + if ($this->arenaId === null) { + return $this->source; + } + + return $this->document->valueMarkup($this->arenaId); + } +} 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..961119aafed --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/Region.php @@ -0,0 +1,201 @@ +opening = $opening; + $this->closing = $closing; + } + + public function opening(): AntlersNode + { + return $this->opening; + } + + public function closing(): AntlersNode + { + return $this->closing; + } + + public function document(): Document + { + return $this->opening->document(); + } + + public function name(): ?string + { + $name = $this->opening->antlersNode()->name; + + if ($name === null) { + return null; + } + + return $name->compound; + } + + public function content(Node|string|null $content = null): NodeSequence|static + { + $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): static + { + $this->content()->rewriteTextEdges($rewrite, $descend); + + return $this; + } + + /** @return LazyCollection */ + public function ownedElements(): LazyCollection + { + return LazyCollection::make(fn () => $this->ownedElementsWithin($this->content()))->values(); + } + + public function unwrap(): static + { + $this->assertOrderedSiblings(); + $this->closing->remove(); + $this->opening->remove(); + + return $this; + } + + public function wrapWith(Element $element): static + { + 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): \Generator + { + $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(LanguageAntlersNode $node): bool + { + 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(): void + { + $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..2c68672f7bc --- /dev/null +++ b/src/View/Antlers/Language/Analyzers/Html/SourceAnchor.php @@ -0,0 +1,22 @@ +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, int $id): self + { + $node = new self($document, $document->arena()->value[$id]); + $node->arenaId = $id; + + return $node; + } + + public function content(?string $content = null): string|static + { + if (func_num_args() === 0) { + return $this->content; + } + + $this->content = (string) $content; + + if ($this->arenaId !== null) { + $this->document->arena()->value[$this->arenaId] = $this->content; + unset($this->document->arena()->sourceStart[$this->arenaId]); + } + $this->touchDocument(); + + return $this; + } + + protected function render(): string + { + if ($this->arenaId === null) { + return $this->content; + } + + return $this->document->valueMarkup($this->arenaId); + } +} 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 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 +418,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; } 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 26aa91a31f1..67cc9450f96 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; @@ -38,19 +52,21 @@ protected function compileBladeComponent(ComponentNode $component, string $prefi $params .= ' component_bound___="'.implode(',', $bound).'"'; } + $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..796507be0fa 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,8 @@ 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; +use Statamic\View\Instrumentation\HtmlContext; class DocumentParser { @@ -97,10 +100,29 @@ 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; + + /** @var string[]|null */ + private $componentPrefixes = null; + private $isInterpolatedParser = false; + /** @var string */ + private $originalContent = ''; + + private $inheritRuntimeLineSeed = true; + private $inputLen = 0; - private $documentOffsets = []; private $documentOffsetKeys = []; private $newlineCount = 0; private $isDoubleBrace = false; @@ -138,6 +160,9 @@ class DocumentParser */ protected $visitors = []; + /** @var NodeVisitorContract[] */ + private $runtimeVisitors = []; + public function __construct() { $this->nodeParser = new AntlersNodeParser(); @@ -174,6 +199,169 @@ 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 + * + * @return bool + */ + public function annotatesHtmlContext() + { + return $this->annotateHtmlContext; + } + + /** + * @internal + * + * @return int + */ + public function generation() + { + return $this->generation; + } + + /** + * @internal + * + * @return void + */ + public function scanHtmlContext() + { + if ($this->htmlContextScanned) { + return; + } + + $this->htmlContextScanned = true; + $scanner = new ContextScanner; + + if ($this->componentPrefixes !== null) { + $scanner->treatComponentsAsDynamic()->withComponentPrefixes($this->componentPrefixes); + } + + $scanner->annotate($this->nodes, $this, $this->html()); + } + + /** + * @internal + * + * @param string[]|null $prefixes + * @return $this + */ + public function withComponentPrefixes($prefixes) + { + $this->componentPrefixes = $prefixes; + + return $this; + } + + /** + * @internal + * + * @param array $contexts + * @return bool + */ + public function adoptHtmlContexts($contexts) + { + if ($this->htmlContextScanned) { + return false; + } + + $antlersNodes = []; + + foreach ($this->nodes as $node) { + if ($node instanceof AntlersNode) { + $antlersNodes[] = $node; + } + } + + if (count($antlersNodes) !== count($contexts)) { + return false; + } + + foreach (array_values($contexts) as $index => $context) { + $antlersNodes[$index]->htmlContext = $context; + } + + $this->htmlContextScanned = true; + + return true; + } + + /** @internal */ + public function html() + { + if ($this->htmlDocument === null) { + $this->htmlDocument = Document::fromParser($this, $this->componentPrefixes ?? []); + } + + return $this->htmlDocument; + } + + /** + * @internal + * + * @return string + */ + public function getOriginalContent() + { + return $this->originalContent; + } + + /** + * @internal + * + * @return static + */ + public function snapshot() + { + $snapshot = clone $this; + + $snapshot->chars = []; + $snapshot->charLen = 0; + $snapshot->currentContent = []; + $snapshot->renderNodes = []; + $snapshot->lastAntlersNode = null; + $snapshot->antlersStartIndex = []; + $snapshot->antlersStartPositionIndex = []; + $snapshot->interpolationRegions = []; + $snapshot->prefixCharacterLengths = []; + $snapshot->neutralizedContent = null; + $snapshot->interpolatedCollisions = []; + $snapshot->interpolatedCollisionCount = []; + $snapshot->threeCharCollisions = []; + $snapshot->visitors = []; + $snapshot->runtimeVisitors = []; + $snapshot->sourceByteOffsets = [ + 0 => 0, + $this->inputLen => strlen($this->content), + ]; + + if ($snapshot->originalContent === $snapshot->content) { + $snapshot->originalContent = $snapshot->content; + } + + return $snapshot; + } + /** * @param int $start * @param int|null $length @@ -271,7 +459,7 @@ private function peek($count) $this->appendSourceChunk(); } - return $this->chars[$count]; + return $this->chars[$count] ?? null; } public function parseIntermediateText() @@ -409,6 +597,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); @@ -423,21 +613,16 @@ private function processInputText($input, ?array $prefix) $this->inputLen => strlen($this->content), ]; - $currentLine = $this->seedStartLine; $newlineCount = 0; - $lastOffset = -1; // A virtual newline just before the document. $scanFromByte = 0; $scanFromCharacter = 0; if ($prefix !== null) { $this->sourceByteOffsets[$prefix['characters']] = $prefix['bytes']; - $this->documentOffsets = $prefix['newlineOffsets']; $this->documentOffsetKeys = $prefix['newlineKeys']; $newlineCount = $prefix['newlineCount']; $this->lineShift = $this->seedStartLine - $prefix['startLine']; - $currentLine = $prefix['startLine'] + $newlineCount; - $lastOffset = $newlineCount > 0 ? $this->documentOffsetKeys[$newlineCount - 1] : -1; $scanFromByte = $prefix['bytes']; $scanFromCharacter = $prefix['characters']; } @@ -454,22 +639,13 @@ private function processInputText($input, ?array $prefix) ); if ($prefix !== null && $newLineByteOffsets !== []) { - $this->documentOffsets = array_slice($this->documentOffsets, 0, $newlineCount, true); $this->documentOffsetKeys = array_slice($this->documentOffsetKeys, 0, $newlineCount); } foreach ($newLineByteOffsets as $newLineByteOffset) { - $thisIndex = $newLineCharacterOffsets[$newLineByteOffset]; - - $this->documentOffsets[$thisIndex] = [ - self::K_CHAR => $thisIndex - $lastOffset, - self::K_LINE => $currentLine, - ]; - $this->documentOffsetKeys[] = $thisIndex; + $this->documentOffsetKeys[] = $newLineCharacterOffsets[$newLineByteOffset]; $newlineCount += 1; - $currentLine += 1; - $lastOffset = $thisIndex; } $this->newlineCount = $newlineCount; @@ -597,7 +773,6 @@ private function interpolatedRegionPrefix($regionText) 'startLine' => $this->seedStartLine - $this->lineShift, 'newlineCount' => $this->newlineCountBefore($prefixCharacters), 'newlineKeys' => $this->documentOffsetKeys, - 'newlineOffsets' => $this->documentOffsets, ]; } @@ -954,6 +1129,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; } @@ -993,6 +1174,7 @@ private function parseDocument($text, ?array $interpolationPrefix) foreach ($this->nodes as $node) { if ($node instanceof AntlersNode) { + $node->parserGeneration = $this->generation; $node->isInterpolationNode = $this->isInterpolatedParser; } @@ -1017,11 +1199,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 +1227,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) + { + $this->runtimeVisitors = $visitors; + } + /** * Clears all registered NodeVisitorContract instances. */ public function clearVisitors() { $this->visitors = []; + $this->runtimeVisitors = []; } private function scanToEndOfPhpRegion($checkChar) @@ -1681,22 +1890,18 @@ public function positionFromOffset($offset, $index, $isRelativeOffset = false) $charToUse = $offset + 1; } else { $nearestIndex = $this->newlineCountBefore($offset); + $lineToUse = $this->seedStartLine + $nearestIndex; if ($nearestIndex === $this->newlineCount) { - $lastOffsetKey = $this->documentOffsetKeys[$nearestIndex - 1]; - $lastOffset = $this->documentOffsets[$lastOffsetKey]; - $lineToUse = $lastOffset[self::K_LINE] + 1 + $this->lineShift; - $charToUse = $offset - $lastOffsetKey; + $charToUse = $offset - $this->documentOffsetKeys[$nearestIndex - 1]; } else { $nearestOffsetIndex = $this->documentOffsetKeys[$nearestIndex]; - $nearestOffset = $this->documentOffsets[$nearestOffsetIndex]; + $previousOffsetIndex = $nearestIndex > 0 ? $this->documentOffsetKeys[$nearestIndex - 1] : -1; if ($nearestOffsetIndex === $offset) { - $lineToUse = $nearestOffset[self::K_LINE] + $this->lineShift; - $charToUse = $nearestOffset[self::K_CHAR]; + $charToUse = $nearestOffsetIndex - $previousOffsetIndex; } elseif ($isRelativeOffset) { - $charToUse = $offset - $nearestOffset[self::K_CHAR]; - $lineToUse = $nearestOffset[self::K_LINE] + $this->lineShift; + $charToUse = $offset - ($nearestOffsetIndex - $previousOffsetIndex); if ($offset <= $nearestOffsetIndex) { $charToUse = $offset + 1; @@ -1704,8 +1909,7 @@ public function positionFromOffset($offset, $index, $isRelativeOffset = false) $lineToUse += 1; } } else { - $charToUse = $nearestOffset[self::K_CHAR] - $nearestOffsetIndex + $offset; - $lineToUse = $nearestOffset[self::K_LINE] + $this->lineShift; + $charToUse = $offset - $previousOffsetIndex; } } } @@ -1765,6 +1969,9 @@ protected function resetIntermediateState() public function resetState() { + $this->htmlDocument = null; + $this->htmlContextScanned = false; + $this->generation++; $this->charLen = 0; $this->antlersStartIndex = []; $this->antlersStartPositionIndex = []; @@ -1773,7 +1980,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]; @@ -1797,7 +2004,6 @@ public function resetState() $this->next = null; $this->prev = null; $this->inputLen = 0; - $this->documentOffsets = []; $this->documentOffsetKeys = []; $this->newlineCount = 0; $this->nodes = []; diff --git a/src/View/Antlers/Language/Parser/LanguageParser.php b/src/View/Antlers/Language/Parser/LanguageParser.php index 9648a41a6c3..890ed666a6e 100644 --- a/src/View/Antlers/Language/Parser/LanguageParser.php +++ b/src/View/Antlers/Language/Parser/LanguageParser.php @@ -1906,6 +1906,12 @@ private function combineVariablePaths($tokens) $newNodes[] = $node; } } elseif ($node instanceof ImplicitArrayBegin && $newNodeCount > 0) { + if (($i + 1) >= $tokenCount) { + $newNodes[] = $node; + + continue; + } + $left = $newNodes[$newNodeCount - 1]; $right = $tokens[$i + 1]; diff --git a/src/View/Antlers/Language/Parser/PathParser.php b/src/View/Antlers/Language/Parser/PathParser.php index 2720847ada9..7a25251490d 100644 --- a/src/View/Antlers/Language/Parser/PathParser.php +++ b/src/View/Antlers/Language/Parser/PathParser.php @@ -423,21 +423,21 @@ private function locateEndOfAccessor() $nestedChars[] = $cur; } - if ($isParsingString == false && ctype_space($next)) { + if ($next == null || ($isParsingString == false && ctype_space($next))) { throw ErrorFactory::makeSyntaxError( AntlersErrorCodes::TYPE_UNEXPECTED_EOI_VARPATH_ACCESSOR, null, 'Unexpected end of input or whitespace while parsing inner variable accessor path.' ); } + } - if ($next == null) { - throw ErrorFactory::makeSyntaxError( - AntlersErrorCodes::TYPE_UNEXPECTED_EOI_VARPATH_ACCESSOR, - null, - 'Unexpected end of input or whitespace while parsing inner variable accessor path.' - ); - } + if ($foundOn < 0) { + throw ErrorFactory::makeSyntaxError( + AntlersErrorCodes::TYPE_UNEXPECTED_EOI_VARPATH_ACCESSOR, + null, + 'Unexpected end of input or whitespace while parsing inner variable accessor path.' + ); } return [$foundOn, $nestedContent]; 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 632bc5142e8..a4b4647b152 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -14,6 +14,7 @@ use Statamic\Contracts\Data\Augmentable; use Statamic\Contracts\Query\Builder; use Statamic\Contracts\View\Antlers\Parser; +use Statamic\Contracts\View\Instrumentation\ComposesRenderedOutput; use Statamic\Fields\ArrayableString; use Statamic\Fields\Value; use Statamic\Fields\Values; @@ -29,6 +30,7 @@ use Statamic\View\Antlers\Language\Errors\ErrorFactory; use Statamic\View\Antlers\Language\Exceptions\RuntimeException; use Statamic\View\Antlers\Language\Exceptions\SyntaxErrorException; +use Statamic\View\Antlers\Language\Facades\Runtime; use Statamic\View\Antlers\Language\Nodes\AbstractNode; use Statamic\View\Antlers\Language\Nodes\AntlersNode; use Statamic\View\Antlers\Language\Nodes\Conditions\ConditionNode; @@ -55,6 +57,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 Stringable; @@ -216,6 +219,12 @@ class NodeProcessor */ private $activeNode = null; + /** @var int */ + private $traceReduceDepth = 0; + + /** @var bool */ + private $htmlInstrumentationActive = false; + /** * A list of all valid PHP opening tags. * @@ -399,6 +408,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. */ @@ -422,6 +443,141 @@ public function setDoStackIntercept($doIntercept = true) return $this; } + /** @return bool */ + private function instrumenting() + { + return $this->htmlInstrumentationActive; + } + + private function suppressesMarkersFor(AntlersNode $node) + { + if (! $this->htmlInstrumentationActive || InstrumentationState::suppressed()) { + return false; + } + + $context = $node->htmlContext(); + + return $context !== null && ! $context->isSafeForHtmlComments(); + } + + private function inHtmlContext(AntlersNode $node, callable $callback) + { + if ($this->suppressesMarkersFor($node)) { + return InstrumentationState::withoutInstrumentation($callback); + } + + return $callback(); + } + + /** + * @param mixed $value + * @return mixed + */ + private function instrumentedOutput(AntlersNode $node, $value) + { + if (! $this->htmlInstrumentationActive) { + return $value; + } + + if ($value instanceof AntlersString) { + return $this->tagBodyOutput($node, $value); + } + + if (! is_string($value) || $value === '') { + return $value; + } + + $outputs = $this->data[count($this->data) - 1][InstrumentationState::twinsKey()] ?? null; + $output = is_array($outputs) ? ($outputs[trim((string) $node->content)] ?? null) : null; + + if (! is_array($output) || $output[0] !== $value || ! $this->canOutputMarkers($node)) { + return $value; + } + + return $output[1]; + } + + /** + * @param array $data + * @param string $name + * @param string $output + * @return array + */ + private function captureNamedSlot($data, $name, $output) + { + if (! $this->instrumenting()) { + $data[$name] = trim($output); + + return $data; + } + + $key = InstrumentationState::twinsKey(); + [$data[$name], $instrumented] = Runtime::splitMarkers((string) $output); + + unset($data[$key][$name]); + + if ($instrumented !== null) { + $data[$key][$name] = [$data[$name], $instrumented]; + } + + return $data; + } + + /** @return bool */ + private function canOutputMarkers(AntlersNode $node) + { + if (InstrumentationState::suppressed()) { + return false; + } + + return $node->htmlContext()?->isSafeForHtmlComments() !== false; + } + + /** + * @param string $output + * @return string + */ + private function slotOutput(AntlersNode $node, $output) + { + if (! $this->htmlInstrumentationActive || $this->canOutputMarkers($node)) { + return $output; + } + + return Runtime::removeMarkers($output); + } + + /** + * @return \Statamic\View\Instrumentation\TagBody|null + */ + private function tagBodyFor(AntlersNode $node, $tagName) + { + if (! $this->htmlInstrumentationActive || $node->runtimeContent === '') { + return null; + } + + $tags = app('statamic.tags'); + $composesOutput = $tags->has($tagName) && is_a($tags->get($tagName), ComposesRenderedOutput::class, true); + + return Runtime::tagBody($node, $composesOutput); + } + + /** + * @param mixed $output + * @return mixed + */ + private function tagBodyOutput(AntlersNode $node, $output) + { + if (! $this->htmlInstrumentationActive || ! $output instanceof AntlersString) { + return $output; + } + + if (! $this->isInterpolationProcessor && $this->canOutputMarkers($node)) { + return $output->instrumented(); + } + + return (string) $output; + } + private function startMeasuringTag($tagName, AntlersNode $node) { $this->profilingTagName = 'tag_'.$tagName.microtime(); @@ -677,6 +833,7 @@ private function shouldProcessAsTag(AntlersNode $node) public function setAntlersParserInstance(Parser $parser) { $this->antlersParser = $parser; + $this->htmlInstrumentationActive = $parser instanceof RuntimeParser && $parser->instrumenting(); return $this; } @@ -1026,9 +1183,12 @@ public function evaluateDeferredNodeAsTag($node, $tagName, $tagMethod, $addition $parameters = []; $runtimeContent = ''; + $tagBody = null; + if ($node instanceof AntlersNode) { $parameters = $node->getParameterValues($this, $this->getActiveData()); - $runtimeContent = $node->runtimeContent; + $tagBody = $this->tagBodyFor($node, $tagName); + $runtimeContent = $tagBody !== null && ! $tagBody->composesOutput ? $tagBody->clean : $node->runtimeContent; } $parameters = array_merge($parameters, $additionalData); @@ -1043,7 +1203,7 @@ public function evaluateDeferredNodeAsTag($node, $tagName, $tagMethod, $addition 'tag_method' => $tagMethod, ]); - return call_user_func([$tag, $tagMethod]); + return InstrumentationState::withTagBody($tagBody, fn () => call_user_func([$tag, $tagMethod])); } /** @@ -1215,6 +1375,58 @@ private function clearInterpolationCache() * @throws TagNotFoundException */ public function reduce($processNodes) + { + $processorDepth = ++$this->traceReduceDepth; + $renderFailure = null; + + try { + if ($this->isInterpolationProcessor && $this->htmlInstrumentationActive) { + return $this->reduceValue($processNodes); + } + + return $this->reduceNodes($processNodes); + } catch (Throwable $throwable) { + $renderFailure = $throwable; + + throw $throwable; + } finally { + $this->traceReduceDepth--; + + if ($this->runtimeConfiguration?->traceManager !== null) { + $this->completeTraceScope($processorDepth, $renderFailure); + } + } + } + + /** + * @param int $processorDepth + * @param Throwable|null $renderFailure + */ + private function completeTraceScope($processorDepth, $renderFailure) + { + try { + $this->runtimeConfiguration->traceManager->traceProcessorScopeComplete($this, $processorDepth); + } catch (Throwable $throwable) { + if ($renderFailure === null) { + throw $throwable; + } + } + } + + private function reduceValue($processNodes) + { + $value = InstrumentationState::withoutInstrumentation( + fn () => $this->reduceNodes($processNodes) + ); + + if (! is_string($value)) { + return $value; + } + + return Runtime::removeMarkers($value); + } + + protected function reduceNodes($processNodes) { $buffer = ''; $processStack = [[$processNodes, 0]]; @@ -1238,12 +1450,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; } @@ -1405,6 +1625,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]; @@ -1475,6 +1699,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) { @@ -1522,13 +1747,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); @@ -1616,7 +1839,7 @@ public function reduce($processNodes) /** @var AntlersNode $slot */ foreach ($namedSlots as $slotName => $slot) { - $tagActiveData['slot:'.$slotName] = trim($slotProcessor->reduce($slot->children)); + $tagActiveData = $this->captureNamedSlot($tagActiveData, 'slot:'.$slotName, $slotProcessor->reduce($slot->children)); } $tagActiveData[GlobalRuntimeState::createIndicatorVariable(GlobalRuntimeState::INDICATOR_NAMED_SLOTS_AVAILABLE)] = true; @@ -1658,11 +1881,13 @@ public function reduce($processNodes) GlobalRuntimeState::$tracedRuntimeAssignments ); } + $tagBody = $this->tagBodyFor($node, $tagToLoad); + /** @var Tags $tag */ $tag = $this->loader->load($tagToLoad, [ 'parser' => $this->antlersParser, 'params' => $tagParameters, - 'content' => $node->runtimeContent, + 'content' => $tagBody !== null && ! $tagBody->composesOutput ? $tagBody->clean : $node->runtimeContent, 'context' => $tagActiveData, 'tag' => $tagName, 'tag_method' => $tagMethod, @@ -1701,7 +1926,9 @@ public function reduce($processNodes) } try { - $output = call_user_func([$tag, $methodToCall], ...$args); + $output = InstrumentationState::withTagBody($tagBody, fn () => $this->suppressesMarkersFor($node) + ? InstrumentationState::withoutInstrumentation(fn () => call_user_func([$tag, $methodToCall], ...$args)) + : call_user_func([$tag, $methodToCall], ...$args)); if ($isCacheTag) { GlobalRuntimeState::$isCacheEnabled = false; @@ -1757,7 +1984,13 @@ public function reduce($processNodes) $this->resolvedBuilder = null; } - RuntimeParser::pushNodeCache($node->runtimeContent, $node->children); + if (! $this->htmlInstrumentationActive || $node->htmlContext()?->isSafeForHtmlComments() !== false) { + RuntimeParser::pushNodeCache( + $node->runtimeContent, + $node->children, + $this->antlersParser instanceof RuntimeParser && $this->antlersParser->annotatesHtmlContext() + ); + } if ($node->name->name == 'yield') { // If the current processor instance is a conditional processor, we @@ -1771,7 +2004,7 @@ public function reduce($processNodes) GlobalRuntimeState::$yieldCount += 1; // Wrap it in a partial thing. $wrapName = 'section:'.$tagMethod.'__yield'.GlobalRuntimeState::$yieldCount; - $bufferOverride = LiteralReplacementManager::registerRegion($wrapName, $tagMethod, $output); + $bufferOverride = LiteralReplacementManager::registerRegion($wrapName, $tagMethod, $this->tagBodyOutput($node, $output)); if (! array_key_exists(GlobalRuntimeState::$environmentId, GlobalRuntimeState::$yieldStacks)) { GlobalRuntimeState::$yieldStacks[GlobalRuntimeState::$environmentId] = []; @@ -1813,7 +2046,7 @@ public function reduce($processNodes) LiteralReplacementManager::registerRegionReplacement( $sectionName, $tagMethod, - $this->cascade->sections()->get($tagMethod) + $this->tagBodyOutput($node, $this->cascade->sections()->get($tagMethod)) ); if (GlobalRuntimeState::$isCacheEnabled) { @@ -1840,6 +2073,8 @@ public function reduce($processNodes) $output = RuntimeValues::resolveWithRuntimeIsolation($output); } + $output = $this->tagBodyOutput($node, $output); + if (! $this->assigningAugmentable($output)) { $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); } @@ -1861,7 +2096,10 @@ public function reduce($processNodes) // Allow tags to return an array. We'll parse it for them. if (is_array($output)) { if (empty($output)) { - $output = $tag->parseNoResults(); + $output = $this->inHtmlContext($node, fn () => InstrumentationState::withTagBody( + $tagBody, + fn () => $this->tagBodyOutput($node, $tag->parseNoResults()) + )); $currentProcessorCanHandleTagValue = false; } else { GlobalRuntimeState::$traceTagAssignments = true; @@ -1873,7 +2111,12 @@ public function reduce($processNodes) // the scope or overall output of the tag result if ($node->hasScopeAdjustingParameters) { $tagAssocOutput = $output; - $output = Arr::assoc($output) ? (string) $tag->parse($output) : (string) $tag->parseLoop($this->addLoopIterationVariables($output)); + $output = $this->inHtmlContext($node, fn () => InstrumentationState::withTagBody( + $tagBody, + fn () => Arr::assoc($output) + ? (string) $this->tagBodyOutput($node, $tag->parse($output)) + : (string) $this->tagBodyOutput($node, $tag->parseLoop($this->addLoopIterationVariables($output))) + )); $tagCallbackResult = null; $currentProcessorCanHandleTagValue = false; @@ -1960,10 +2203,6 @@ public function reduce($processNodes) $buffer .= $this->measureBufferAppend($node, $results); - if ($this->isTracingEnabled()) { - $this->runtimeConfiguration->traceManager->traceOnExit($node, $results); - } - continue; } @@ -2120,14 +2359,14 @@ public function reduce($processNodes) if ($this->guardRuntime($node, $runtimeResult)) { if ($runtimeResult instanceof Slot) { $lockData = $this->data; - $runtimeResult = $runtimeResult->render(); + $runtimeResult = $this->slotOutput($node, $runtimeResult->render()); $this->data = $lockData; + } else { + $runtimeResult = $this->instrumentedOutput($node, $runtimeResult); } $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($runtimeResult)); - } - - if ($this->isTracingEnabled()) { + } elseif ($this->isTracingEnabled()) { $this->runtimeConfiguration->traceManager->traceOnExit($node, null); } @@ -2209,7 +2448,7 @@ public function reduce($processNodes) if ($val instanceof Slot) { $lockData = $this->data; - $val = $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : []); + $val = $this->slotOutput($node, $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : [])); $this->data = $lockData; $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($val)); @@ -2501,9 +2740,7 @@ public function reduce($processNodes) $buffer .= $this->measureBufferAppend($node, $loopBuffer); } - } - - if ($this->isTracingEnabled()) { + } elseif ($this->isTracingEnabled()) { $this->runtimeConfiguration->traceManager->traceOnExit($node, null); } @@ -2531,9 +2768,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..3c1b1a96fde 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,48 @@ 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 + )); + } + + /** + * @param callable|null $previous + * @param callable|null $replacement + * @return void + */ + public function replacePreparser($previous, $replacement) + { + $index = $previous === null ? false : array_search($previous, $this->preparsers, true); + + if ($index === false) { + if ($replacement !== null) { + $this->preparse($replacement); + } + + return; + } + + if ($replacement === null || in_array($replacement, $this->preparsers, true)) { + array_splice($this->preparsers, $index, 1); + + return; + } + + $this->preparsers[$index] = $replacement; + } + /** * Gets all registered Antlers preparsers. * @@ -153,6 +195,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,9 +228,26 @@ public function getPreparsers() */ public function addVisitor(NodeVisitorContract $visitor) { + if (in_array($visitor, $this->visitors, true)) { + return; + } + $this->visitors[] = $visitor; } + /** @return void */ + public function removeVisitor(NodeVisitorContract $visitor) + { + if (! in_array($visitor, $this->visitors, true)) { + return; + } + + $this->visitors = array_values(array_filter( + $this->visitors, + fn ($registered) => $registered !== $visitor + )); + } + /** * Returns all registered node visitors. * diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index 816438d7699..31c64848175 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -19,6 +19,7 @@ use Statamic\View\Antlers\Language\Exceptions\AntlersException; use Statamic\View\Antlers\Language\Exceptions\RuntimeException; use Statamic\View\Antlers\Language\Exceptions\SyntaxErrorException; +use Statamic\View\Antlers\Language\Facades\Runtime; use Statamic\View\Antlers\Language\Lexer\AntlersLexer; use Statamic\View\Antlers\Language\Nodes\AbstractNode; use Statamic\View\Antlers\Language\Nodes\AntlersNode; @@ -31,11 +32,16 @@ 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\InstrumentationManager; +use Statamic\View\Instrumentation\InstrumentationState; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; class RuntimeParser implements Parser { + const CAPTURED_CONTENT = '__captured'; + /** * The current DocumentParser instance. * @@ -69,6 +75,34 @@ class RuntimeParser implements Parser */ private $allowPhp = false; + /** @var RuntimeConfiguration|null */ + protected $runtimeConfiguration; + + /** @var \Statamic\View\Antlers\Language\Runtime\Tracing\NodeVisitorContract[] */ + protected $syncedVisitors = []; + + /** @var bool */ + protected $htmlInstrumentationActive = false; + + /** @var InstrumentationManager|null */ + private $instrumentation = null; + + /** @var array{0: callable[], 1: callable[], 2: HtmlInstrumentation[], 3: callable[]}|null */ + private $instrumenterCache = null; + + /** + * @var array + */ + private static $viewSourceSeeds = []; + + /** + * @var array + */ + private static $newLineStyles = []; + + /** @var int */ + protected static $activeRenders = 0; + /** * A list of pre-parsers. * @@ -120,12 +154,13 @@ class RuntimeParser implements Parser protected $componentCompiler; - public function __construct(DocumentParser $documentParser, NodeProcessor $nodeProcessor, AntlersLexer $lexer, LanguageParser $antlersParser) + public function __construct(DocumentParser $documentParser, NodeProcessor $nodeProcessor, AntlersLexer $lexer, LanguageParser $antlersParser, ?InstrumentationManager $instrumentation = null) { $this->documentParser = $documentParser; $this->nodeProcessor = $nodeProcessor; $this->antlersLexer = $lexer; $this->antlersParser = $antlersParser; + $this->instrumentation = $instrumentation; $this->componentCompiler = new ComponentCompiler(); } @@ -138,6 +173,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 +186,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 +199,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; @@ -198,17 +236,182 @@ protected function sanitizePhp($text) * Executes all pre-parser callbacks on the input text. * * @param string $text The text to pre-parse. + * @param callable[]|null $preParsers * @return string */ - protected function runPreParserCallbacks($text) + protected function runPreParserCallbacks($text, $preParsers = null, $instrument = true) { - $value = $text; + $value = $text ?? ''; + $preParsers ??= $this->preParserState()[3]; + + if ($preParsers === []) { + return $value; + } + + $instrumenters = []; + + foreach ($preParsers as $preParser) { + if ($preParser instanceof HtmlInstrumentation) { + if ($instrument) { + $instrumenters[] = $preParser; + } + + continue; + } + + if ($instrumenters !== []) { + $value = HtmlInstrumentation::instrumentTogether($value ?? '', $instrumenters); + $instrumenters = []; + } - foreach ($this->preParsers as $preParser) { $value = call_user_func($preParser, $value); } - return $value; + if ($instrumenters === []) { + return $value; + } + + return HtmlInstrumentation::instrumentTogether($value ?? '', $instrumenters); + } + + /** + * @internal + * + * @return bool + */ + public function annotatesHtmlContext() + { + return $this->documentParser->annotatesHtmlContext(); + } + + /** + * @param string $text + * @return void + */ + private function adoptHtmlContexts($text) + { + $contexts = HtmlInstrumentation::annotationsFor($text); + + if ($contexts !== null) { + $this->documentParser->adoptHtmlContexts($contexts); + } + } + + /** + * @internal + * + * @return bool + */ + public static function isRendering() + { + return self::$activeRenders > 0; + } + + /** + * @internal + * + * @return bool + */ + public function instrumenting() + { + return $this->htmlInstrumentationActive; + } + + /** + * @internal + * + * @return bool + */ + public function hasInstrumenters() + { + return $this->htmlInstrumenters() !== []; + } + + /** @return HtmlInstrumentation[] */ + private function htmlInstrumenters() + { + return $this->preParserState()[2]; + } + + /** + * @return array{0: callable[], 1: callable[], 2: HtmlInstrumentation[], 3: callable[]} + */ + private function preParserState() + { + $configured = $this->runtimeConfiguration?->getPreparsers() ?? []; + $cache = $this->instrumenterCache; + + if ($cache !== null && $cache[0] === $configured && $cache[1] === $this->preParsers) { + return $cache; + } + + $preParsers = $this->mergeConfigurationCallbacks($this->preParsers, 'getPreparsers'); + $instrumenters = array_values(array_filter( + $preParsers, + fn ($preparser) => $preparser instanceof HtmlInstrumentation + )); + + return $this->instrumenterCache = [$configured, $this->preParsers, $instrumenters, $preParsers]; + } + + /** + * @param string $text + * @param string $key + * @return string + */ + private function newLineStyle($text, $key) + { + $key = ($this->htmlInstrumentationActive ? 'i' : 'p').$key; + + if (isset(self::$newLineStyles[$key])) { + return self::$newLineStyles[$key]; + } + + if (count(self::$newLineStyles) >= 512) { + self::$newLineStyles = array_slice(self::$newLineStyles, 256, null, true); + } + + return self::$newLineStyles[$key] = StringUtilities::detectNewLineStyle( + $this->htmlInstrumentationActive ? HtmlInstrumentation::removeMarkers($text, $this->htmlInstrumenters()) : $text + ); + } + + /** + * @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(); + + if ($visitors === $this->syncedVisitors) { + return; + } + + $this->documentParser->setRuntimeVisitors($visitors); + $this->syncedVisitors = $visitors; } /** @@ -254,6 +457,42 @@ private function getFrontMatterLineOffset($path) return 1; } + /** + * @param string $path + * @param string $text + * @return array{0: int, 1: int, 2: bool} + */ + private function getViewSourceSeed($path, $text) + { + if (! is_string($text) || ! is_file($path)) { + return [1, 0, true]; + } + + $signature = [filemtime($path), filesize($path), hash('xxh3', $text)]; + $cached = self::$viewSourceSeeds[$path] ?? null; + + if ($cached !== null && $cached[0] === $signature) { + return $cached[1]; + } + + $contents = file_get_contents($path); + + if (! str_ends_with($contents, $text)) { + $seed = [1, 0, false]; + } else { + $frontMatter = substr($contents, 0, strlen($contents) - strlen($text)); + $seed = [substr_count($frontMatter, "\n") + 1, mb_strlen($frontMatter), true]; + } + + if (count(self::$viewSourceSeeds) >= 128) { + self::$viewSourceSeeds = array_slice(self::$viewSourceSeeds, 64, null, true); + } + + self::$viewSourceSeeds[$path] = [$signature, $seed]; + + return $seed; + } + /** * Adds a string preparser to the list of executable preparsers. * @@ -290,10 +529,17 @@ protected function canPossiblyParseAntlers($text) * * @param string $text The source content. * @param AbstractNode[] $nodes The parsed nodes. + * @param bool $annotatedHtmlContext */ - public static function pushNodeCache($text, $nodes) + public static function pushNodeCache($text, $nodes, $annotatedHtmlContext = false) { - self::$standardRenderNodeCache[md5($text)] = $nodes; + $cacheSlug = md5($text); + + if ($annotatedHtmlContext) { + $cacheSlug .= ':html'; + } + + self::$standardRenderNodeCache[$cacheSlug] = $nodes; } /** @@ -339,33 +585,104 @@ protected function shouldCacheRenderNodes($text) * @throws TagNotFoundException * @throws Throwable */ - protected function renderText($text, $data = []) + protected function renderText($text, $data = [], $isView = false, $instrument = true) { - $text = $this->componentCompiler->compile($text); + if (self::$activeRenders === 0) { + $this->instrumentation?->sync(); + } + + if (GlobalRuntimeState::$isEvaluatingUserData + && ! InstrumentationState::suppressed() + && $this->hasInstrumenters()) { + return InstrumentationState::withoutInstrumentation( + fn () => $this->renderText($text, $data, $isView, $instrument) + ); + } + + if (self::$activeRenders === 0 + && ! InstrumentationState::suppressed() + && $this->hasInstrumenters() + && ! InstrumentationState::bufferedOutputIsCommentSafe()) { + return InstrumentationState::withoutInstrumentation( + fn () => $this->renderText($text, $data, $isView, $instrument) + ); + } $this->parseStack += 1; - $text = $this->runPreParserCallbacks($text); + $parseStackReleased = false; + $isOutermostRender = ++self::$activeRenders === 1; - if (! $this->canPossiblyParseAntlers($text)) { - $text = $this->sanitizePhp($text); + try { + $preParserState = $this->preParserState(); + $this->htmlInstrumentationActive = $preParserState[2] !== []; + + if ($this->htmlInstrumentationActive) { + $tagBody = $isView || ! is_string($text) ? null : InstrumentationState::activeTagBody()?->align($text); - $this->parseStack -= 1; + if ($tagBody !== null) { + $text = $tagBody; + $instrument = false; + } - if ($text == null) { - return new AntlersString('', $this); + $sourceSeed = [1, 0, strlen($this->view) > 0]; + + if ($sourceSeed[2] && $instrument) { + $sourceSeed = $this->getViewSourceSeed($this->view, $text); + } + + $text = $this->componentCompiler->compile($text, true); + $text = InstrumentationState::withSourceMap( + $this->componentCompiler->sourceMap(), + fn () => InstrumentationState::withSourceSeed( + $sourceSeed, + fn () => $this->runPreParserCallbacks($text, $preParserState[3], $instrument) + ) + ); + } else { + $text = $this->runPreParserCallbacks($this->componentCompiler->compile($text), $preParserState[3]); } - return new AntlersString($text, $this); - } + if (! $this->canPossiblyParseAntlers($text)) { + $text = $this->sanitizePhp($text); - $newLineStyle = StringUtilities::detectNewLineStyle($text); - $bufferContent = ''; + $this->parseStack -= 1; + $parseStackReleased = true; - try { + if ($text == null) { + return new AntlersString('', $this); + } + + return new AntlersString($text, $this); + } + + $bufferContent = ''; $parseText = $this->sanitizePhp($text); $cacheSlug = md5($parseText); + $newLineStyle = $this->newLineStyle($text, $cacheSlug); + $annotateHtmlContext = $this->documentParser->annotatesHtmlContext(); + + if ($this->runtimeConfiguration !== null) { + if ($this->runtimeConfiguration->annotateHtmlContext !== $annotateHtmlContext) { + $annotateHtmlContext = $this->runtimeConfiguration->annotateHtmlContext; + $this->documentParser->annotateHtmlContext($annotateHtmlContext); + } + + $this->syncRuntimeVisitors(); + } + + if ($annotateHtmlContext) { + $cacheSlug .= ':html'; + } if (! array_key_exists($cacheSlug, self::$standardRenderNodeCache) || ! $this->shouldCacheRenderNodes($text)) { + $previousParser = null; + + foreach ($this->documentParser->getNodes() as $node) { + if ($node instanceof AntlersNode) { + $node->withParser($previousParser ??= $this->documentParser->snapshot()); + } + } + $this->documentParser->setIsVirtual($this->view == ''); if (strlen($this->view) > 0) { @@ -383,7 +700,14 @@ protected function renderText($text, $data = []) $this->documentParser->setStartLineSeed($lastTagNode->endPosition->line); } } + $this->documentParser->withComponentPrefixes( + $this->htmlInstrumentationActive ? $preParserState[2][0]->getComponentPrefixes() : null + ); self::$standardRenderNodeCache[$cacheSlug] = $this->documentParser->parse($parseText); + + if ($this->htmlInstrumentationActive && $parseText === $text) { + $this->adoptHtmlContexts($text); + } } $renderNodes = self::$standardRenderNodeCache[$cacheSlug]; @@ -393,9 +717,54 @@ 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->nodeProcessor->getRuntimeConfiguration()?->traceManager; + $initialTraceDepth = 0; + $renderCompleted = false; + + if ($initialTraceManager !== null) { + $initialTraceDepth = $initialTraceManager->renderDepth(); + $this->nodeProcessor->triggerRenderStart(); + } + + 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(); + } + + if ($isOutermostRender && $this->htmlInstrumentationActive) { + $bufferContent = $this->instrumentation !== null + ? $this->instrumentation->removeMisplacedMarkers($bufferContent, $this->htmlInstrumenters()) + : HtmlInstrumentation::removeMisplacedMarkers($bufferContent, $this->htmlInstrumenters()); + } + + $renderCompleted = true; + + return $this->antlersString($bufferContent, $isView, $isOutermostRender); + } finally { + if ($initialTraceManager !== null || $this->nodeProcessor->getRuntimeConfiguration()?->traceManager !== null) { + $this->completeRenderTracing($initialTraceManager, $initialTraceDepth, $renderCompleted, $isOutermostRender); + } + } } catch (AntlersException $antlersException) { if ($this->isIgnitionInstalled()) { throw $this->buildAntlersExceptionError($antlersException, $text, $data); @@ -432,30 +801,66 @@ protected function renderText($text, $data = []) } throw $this->addAntlersErrorDetails($throwable, $text, $data); + } finally { + self::$activeRenders--; + + if (! $parseStackReleased) { + $this->parseStack -= 1; + } } + } - if ($newLineStyle != "\n") { - if (Str::contains($bufferContent, "\r\n") == false) { - $bufferContent = str_replace("\n", "\r\n", $bufferContent); + /** + * @param string $content + * @param bool $isView + * @param bool $isOutermostRender + * @return AntlersString + */ + private function antlersString($content, $isView, $isOutermostRender) + { + if ($this->htmlInstrumentationActive && ! $isView && ! $isOutermostRender && ! InstrumentationState::activeTagBody()?->composesOutput) { + $instrumentation = $this->instrumentation ??= Runtime::getFacadeRoot(); + + if ($instrumentation->containsMarkers($content)) { + return AntlersString::withInstrumentation($content, $this, $instrumentation); } } - $this->parseStack -= 1; + return new AntlersString($content, $this); + } - if ($this->parseStack == 0 && GlobalRuntimeState::$containsLayout == false) { - $bufferContent = LiteralReplacementManager::processReplacements($bufferContent); - $bufferContent = StackReplacementManager::processReplacements($bufferContent); + private function completeRenderTracing(?Tracing\TraceManager $initialManager, int $initialDepth, bool $renderCompleted, bool $isOutermostRender) + { + $currentManager = $this->nodeProcessor->getRuntimeConfiguration()?->traceManager; + $managers = []; + + 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 ($isOutermostRender + && $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() @@ -692,9 +1097,14 @@ private function cloneRuntimeParser() $parser = (new RuntimeParser( $this->documentParser, $this->nodeProcessor->cloneProcessor(), - $this->antlersLexer, $this->antlersParser + $this->antlersLexer, $this->antlersParser, + $this->instrumentation ))->allowPhp($this->allowPhp); + $parser->runtimeConfiguration = $this->runtimeConfiguration; + $parser->instrumenterCache = $this->instrumenterCache; + $parser->syncedVisitors = &$this->syncedVisitors; + foreach ($this->preParsers as $preParser) { $parser->preparse($preParser); } @@ -723,13 +1133,13 @@ private function shouldIsolate() return false; } - public function parse($text, $data = []) + public function parse($text, $data = [], $instrument = true) { if ($this->shouldIsolate()) { - return $this->cloneRuntimeParser()->renderText($text, $data); + return $this->cloneRuntimeParser()->renderText($text, $data, false, $instrument); } - return $this->renderText($text, $data); + return $this->renderText($text, $data, false, $instrument); } public function render($text, $data = []) @@ -766,6 +1176,9 @@ public function cascade($cascade) public function parseView($view, $text, $data = []) { + $instrument = ! ($data[self::CAPTURED_CONTENT] ?? false); + unset($data[self::CAPTURED_CONTENT]); + $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = false; @@ -777,7 +1190,7 @@ public function parseView($view, $text, $data = []) $suspendedAssignments = $shouldSwapData ? $this->nodeProcessor->getRuntimeAssignments() : null; try { - return $this->renderViewContent($view, $text, $data); + return $this->renderViewContent($view, $text, $data, $instrument); } finally { if ($shouldSwapData) { $this->nodeProcessor->swapData($suspendedData); @@ -790,7 +1203,7 @@ public function parseView($view, $text, $data = []) } } - private function renderViewContent($view, $text, $data = []) + private function renderViewContent($view, $text, $data = [], $instrument = true) { $this->view = $view; GlobalRuntimeState::$templateFileStack[] = [$view, null]; @@ -809,7 +1222,7 @@ private function renderViewContent($view, $text, $data = []) 'view' => $this->cascade->getViewData($view), ]); - return $this->renderText($text, $data); + return $this->renderText($text, $data, true, $instrument); } public function injectNoparse($text) 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..8a85e2fc3b8 --- /dev/null +++ b/src/View/Antlers/Language/Runtime/Tracing/ProcessorAwareTracerContract.php @@ -0,0 +1,11 @@ +, 3: int|null, 4: int}> */ + protected $frames = []; + + /** @var bool */ + protected $hasUnrememberedSpanTracers = false; + + /** @var int[] */ + protected $renderBoundaries = []; + + /** @var array{0: int, 1: int}|null */ + protected $implicitRenderScope = null; + + /** @var WeakMap|null */ + protected static $openRenders = null; + + /** @var TracerContract[] */ + protected static $renderCompletionTracers = []; + + /** @param RuntimeTracerContract|TracerContract $tracer */ + public function registerTracer($tracer) { - $this->tracers[] = $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; + $this->hasUnrememberedSpanTracers = true; + + return; + } + + throw new \InvalidArgumentException( + 'Tracers must implement RuntimeTracerContract or TracerContract.' + ); } - public function traceOnEnter(AbstractNode $node) + /** + * @param RuntimeTracerContract|TracerContract $tracer + * @return void + */ + public function removeTracer($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 + )); + } + + /** @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 + ) { + $processorId = $processor === null ? null : spl_object_id($processor); + + if ($this->renderBoundaries === []) { + $this->traceRenderStart(); + + if ($processorId !== null && ! RuntimeParser::isRendering()) { + $this->implicitRenderScope = [$processorId, $processorDepth]; + } + } + + if ($processorId !== null && $processorDepth > 0 && $this->frames !== []) { + $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 === []) { + return; + } + + if ($this->hasUnrememberedSpanTracers) { + foreach ($this->spanTracers as $tracer) { + $this->rememberRenderCompletionTracer($tracer); + } + + $this->hasUnrememberedSpanTracers = false; + } + + $span = Span::antlersNode($node, $processor, $processorDepth); + $handles = []; + + try { + foreach ($this->spanTracers as $tracer) { + $handles[] = [$tracer, $tracer->onEnter($span)]; + } + } catch (Throwable $enterFailure) { + try { + $this->closeHandles($span, $handles, null); + } catch (Throwable) { + } + + throw $enterFailure; + } + + $this->frames[] = [spl_object_id($node), $span, $handles, $processorId, $processorDepth]; + } + + public function traceProcessorScopeComplete(NodeProcessor $processor, int $processorDepth) + { + if ($this->frames !== []) { + $this->closeProcessorScopeFrames($processor, $processorDepth); + } + + if ($this->implicitRenderScope !== null + && $this->implicitRenderScope[1] === $processorDepth + && $this->implicitRenderScope[0] === spl_object_id($processor)) { + $this->traceRenderComplete(false); + } } 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][0] === $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 traceRenderComplete() + public function traceRenderStart() { - foreach ($this->tracers as $tracer) { - $tracer->onRenderComplete(); + self::$openRenders ??= new WeakMap; + + if (count(self::$openRenders) === 0) { + self::$renderCompletionTracers = []; + } + + foreach ($this->spanTracers as $tracer) { + $this->rememberRenderCompletionTracer($tracer); + } + + $this->hasUnrememberedSpanTracers = false; + $this->renderBoundaries[] = count($this->frames); + self::$openRenders[$this] = true; + } + + /** @return int */ + public function renderDepth() + { + return count($this->renderBoundaries); + } + + /** @param bool $notifyTracers */ + public function traceRenderComplete($notifyTracers = true) + { + $boundary = array_pop($this->renderBoundaries) ?? 0; + $failure = null; + self::$openRenders ??= new WeakMap; + + if ($this->renderBoundaries === []) { + $this->implicitRenderScope = null; + unset(self::$openRenders[$this]); + } + + while (count($this->frames) > $boundary) { + try { + $this->closeFrame(array_pop($this->frames), null); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + + if ($notifyTracers) { + foreach ($this->tracers as $tracer) { + try { + $tracer->onRenderComplete(); + } catch (Throwable $throwable) { + $failure ??= $throwable; + } + } + } + + if ($this->renderBoundaries === []) { + $this->lastProcessor = null; + } + + if (count(self::$openRenders) > 0) { + if ($failure !== null) { + throw $failure; + } + + return; + } + + $completionTracers = self::$renderCompletionTracers; + self::$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, self::$renderCompletionTracers, true)) { + self::$renderCompletionTracers[] = $tracer; + } + } + + /** @param array{0: int, 1: Span, 2: array, 3: int|null, 4: int} $frame */ + protected function closeFrame(array $frame, $runtimeContent) + { + $this->closeHandles($frame[1], $frame[2], $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) + { + 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][3] === $processorId && $this->frames[$index][4] === $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..e5ad9c190a5 100644 --- a/src/View/Antlers/Language/Utilities/CharacterOffsets.php +++ b/src/View/Antlers/Language/Utilities/CharacterOffsets.php @@ -147,6 +147,65 @@ protected static function toBytesByteLoop($source, array $characterOffsets) return $offsets; } + /** + * @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) { + 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/Instrumentation/Antlers/ComponentSourceMap.php b/src/View/Instrumentation/Antlers/ComponentSourceMap.php new file mode 100644 index 00000000000..3eaeb345ae7 --- /dev/null +++ b/src/View/Instrumentation/Antlers/ComponentSourceMap.php @@ -0,0 +1,113 @@ +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 + */ + 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; + + if (isset($segments[$index]) && $offset > $segments[$index]['compiledStart']) { + $original = $segments[$index][$boundary]; + } + + $mapped[$offset] = $original; + } + + return $mapped; + } + + /** @return array */ + 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..e951b167612 --- /dev/null +++ b/src/View/Instrumentation/Antlers/ContextScanner.php @@ -0,0 +1,387 @@ +> */ + protected array $embeddedPositions = []; + + /** @var array */ + protected array $reportableChains = []; + + public function __construct() + { + $this->inspector = new ElementInspector; + } + + public function treatComponentsAsDynamic(bool $dynamic = true): static + { + $this->treatComponentsAsDynamic = $dynamic; + + return $this; + } + + /** @param string[] $prefixes */ + public function withComponentPrefixes(array $prefixes): static + { + $this->componentPrefixes = $prefixes; + + return $this; + } + + /** @param AbstractNode[] $nodes */ + public function annotate(array $nodes, ?DocumentParser $parser = null, ?Document $document = null): void + { + $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); + $this->reportableChains = []; + + $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 + * @return array{0: HtmlContext, 1: int|null} + */ + protected function contextFromPlacement(?array $placement, Document $document, string $source): array + { + 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( + $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 === 'php') { + $context = new HtmlContext(HtmlContext::KIND_PHP, 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( + $kind, + $this->embeddedElementName($kind, $partial), + $attribute, + $chain, + null, + substr($source, $placement['markupStart'], 2) === ' $placement + * @return array{0: string, 1: string|null, 2: string} + */ + protected function classifyEmbedded(array $placement, string $source): array + { + $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): array + { + if ($chain === []) { + return []; + } + + $key = implode('/', $chain); + + if (isset($this->reportableChains[$key])) { + return $this->reportableChains[$key]; + } + + $reportable = []; + + foreach ($chain as $name) { + $reportable[] = $this->reportableName($name) ?? HtmlContext::DYNAMIC_ELEMENT; + } + + return $this->reportableChains[$key] = $reportable; + } + + protected function reportableName(?string $name): ?string + { + if ($name === null) { + return null; + } + + if ($this->treatComponentsAsDynamic && ComponentPrefixes::matches($name, $this->componentPrefixes)) { + return null; + } + + return $name; + } + + /** @param string[] $chain */ + protected function innermostStaticName(array $chain): ?string + { + if ($chain === []) { + return null; + } + + $innermost = $chain[count($chain) - 1]; + + if ($innermost === HtmlContext::DYNAMIC_ELEMENT) { + return null; + } + + return $innermost; + } + + protected function contentKindForPlacement(string $bucket): string + { + if ($bucket === 'raw-text') { + return HtmlContext::KIND_RAW_TEXT; + } + + return HtmlContext::KIND_ELEMENT_CONTENT; + } + + protected function shouldAppendDynamicOwner(?string $ownerName, ?string $reportableOwner, array $chain): bool + { + if ($ownerName === null || $reportableOwner !== null) { + return false; + } + + return $chain === [] || $chain[count($chain) - 1] !== HtmlContext::DYNAMIC_ELEMENT; + } + + protected function embeddedElementName(string $kind, string $partial, ?string $fallback = null): ?string + { + if ($kind === HtmlContext::KIND_ELEMENT_NAME && $partial !== '') { + return $partial; + } + + return $fallback; + } + + protected function dynamicComponentContext(HtmlContext $context): HtmlContext + { + $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): bool + { + return ComponentCompiler::isComponentProxyContent((string) $node->content); + } + + protected function applyContext(AbstractNode $node, HtmlContext $context): void + { + if (! $node instanceof LanguageAntlersNode) { + return; + } + + $node->htmlContext = $context; + + 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..dde5256bd1e --- /dev/null +++ b/src/View/Instrumentation/Antlers/ElementInspector.php @@ -0,0 +1,511 @@ +startsEmbedded($template, $index)) { + $end = $this->embeddedEnd($template, $index, $length); + + if ($end === false) { + return substr($template, $offset); + } + + $state = $this->stateAfterAntlers($state); + $index = $end - 1; + + continue; + } + + if ($state === self::STATE_DOUBLE_QUOTED || $state === self::STATE_SINGLE_QUOTED) { + if ($this->isClosingQuote($state, $char)) { + $state = self::STATE_BEFORE_ATTRIBUTE; + } + + continue; + } + + if ($char === '>' && $state !== self::STATE_TAG_NAME) { + return substr($template, $offset, $index + 1 - $offset); + } + + if ($state === self::STATE_TAG_NAME) { + if ($this->isHtmlSpace($char)) { + $state = self::STATE_BEFORE_ATTRIBUTE; + } elseif ($char === '/') { + $state = self::STATE_SELF_CLOSING; + } elseif ($char === '>') { + return substr($template, $offset, $index + 1 - $offset); + } + } elseif ($state === self::STATE_BEFORE_ATTRIBUTE) { + if ($char === '/') { + $state = self::STATE_SELF_CLOSING; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::STATE_ATTRIBUTE_NAME; + } + } elseif ($state === self::STATE_ATTRIBUTE_NAME) { + if ($this->isHtmlSpace($char)) { + $state = self::STATE_AFTER_ATTRIBUTE; + } elseif ($char === '/') { + $state = self::STATE_SELF_CLOSING; + } elseif ($char === '=') { + $state = self::STATE_BEFORE_VALUE; + } + } elseif ($state === self::STATE_AFTER_ATTRIBUTE) { + if ($char === '/') { + $state = self::STATE_SELF_CLOSING; + } elseif ($char === '=') { + $state = self::STATE_BEFORE_VALUE; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::STATE_ATTRIBUTE_NAME; + } + } elseif ($state === self::STATE_BEFORE_VALUE) { + if ($char === '"') { + $state = self::STATE_DOUBLE_QUOTED; + } elseif ($char === "'") { + $state = self::STATE_SINGLE_QUOTED; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::STATE_UNQUOTED_VALUE; + } + } elseif ($state === self::STATE_UNQUOTED_VALUE) { + if ($this->isHtmlSpace($char)) { + $state = self::STATE_BEFORE_ATTRIBUTE; + } + } elseif ($state === self::STATE_SELF_CLOSING) { + $state = self::STATE_BEFORE_ATTRIBUTE; + $index--; + } + } + + return substr($template, $offset); + } + + public function attributeInsertionOffset(string $template, int $startByteOffset): int + { + $length = strlen($template); + $index = $startByteOffset + 1; + + while ($index < $length + && ! HtmlSpec::isTagNameDelimiter($template[$index])) { + if ($this->startsEmbedded($template, $index)) { + $end = $this->embeddedEnd($template, $index, $length); + + if ($end === false) { + break; + } + + $index = $end; + } else { + $index++; + } + } + + return $index; + } + + /** + * @param int[] $offsets + * @return array + */ + public function classifyTagPositions(string $markup, array $offsets): array + { + $length = strlen($markup); + $needed = array_fill_keys($offsets, true); + $classified = []; + $state = self::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 ($this->startsEmbedded($markup, $index)) { + $end = $this->embeddedEnd($markup, $index, $length); + + if ($end === false) { + break; + } + + if ($markup[$index] === '<') { + foreach ($needed as $offset => $unused) { + if ($offset > $index && $offset < $end && ! isset($classified[$offset])) { + $classified[$offset] = $this->classifyTagState($markup, $offset, $length, $state, $tagName, $attributeName, $pendingValueOwner); + } + } + } + + if ($state === self::STATE_BEFORE_ATTRIBUTE || $state === self::STATE_AFTER_ATTRIBUTE) { + $attributeName = ''; + $pendingValueOwner = ''; + } + + $state = $this->stateAfterAntlers($state); + $index = $end; + + continue; + } + + $char = $markup[$index]; + + if ($state === self::STATE_DOUBLE_QUOTED || $state === self::STATE_SINGLE_QUOTED) { + if ($this->isClosingQuote($state, $char)) { + $state = self::STATE_BEFORE_ATTRIBUTE; + $attributeName = ''; + } + + $index++; + + continue; + } + + switch ($state) { + case self::STATE_TAG_NAME: + if ($this->isHtmlSpace($char) || $char === '/') { + $state = self::STATE_BEFORE_ATTRIBUTE; + } elseif ($char === '>') { + $state = self::STATE_DONE; + } else { + $tagName .= $char; + } + break; + case self::STATE_BEFORE_ATTRIBUTE: + if (! $this->isHtmlSpace($char) && $char !== '/' && $char !== '>') { + $state = self::STATE_ATTRIBUTE_NAME; + $attributeName = $char; + } + break; + case self::STATE_ATTRIBUTE_NAME: + if ($char === '=') { + $pendingValueOwner = $attributeName; + $state = self::STATE_BEFORE_VALUE; + } elseif ($this->isHtmlSpace($char)) { + $pendingValueOwner = $attributeName; + $state = self::STATE_AFTER_ATTRIBUTE; + } elseif ($char === '/' || $char === '>') { + $state = self::STATE_BEFORE_ATTRIBUTE; + $attributeName = ''; + } else { + $attributeName .= $char; + } + break; + case self::STATE_AFTER_ATTRIBUTE: + if ($char === '=') { + $state = self::STATE_BEFORE_VALUE; + } elseif ($char === '/') { + $state = self::STATE_BEFORE_ATTRIBUTE; + $attributeName = ''; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::STATE_ATTRIBUTE_NAME; + $attributeName = $char; + } + break; + case self::STATE_BEFORE_VALUE: + if ($char === '"') { + $state = self::STATE_DOUBLE_QUOTED; + } elseif ($char === "'") { + $state = self::STATE_SINGLE_QUOTED; + } elseif (! $this->isHtmlSpace($char)) { + $state = self::STATE_UNQUOTED_VALUE; + } + break; + case self::STATE_UNQUOTED_VALUE: + if ($this->isHtmlSpace($char)) { + $state = self::STATE_BEFORE_ATTRIBUTE; + $attributeName = ''; + } + break; + } + + $index++; + } + + foreach ($needed as $offset => $unused) { + if (! isset($classified[$offset])) { + $classified[$offset] = [HtmlContext::KIND_TAG_OPEN, null, '']; + } + } + + return $classified; + } + + /** @return array{0: string, 1: string|null, 2: string} */ + protected function classifyTagState(string $markup, int $index, int $length, string $state, string $tagName, string $attributeName, string $pendingValueOwner): array + { + switch ($state) { + case self::STATE_TAG_NAME: + return [HtmlContext::KIND_ELEMENT_NAME, null, $tagName]; + case self::STATE_ATTRIBUTE_NAME: + return [HtmlContext::KIND_ATTRIBUTE_NAME, $this->normalizedAttributeName($attributeName), $attributeName]; + case self::STATE_DOUBLE_QUOTED: + case self::STATE_SINGLE_QUOTED: + case self::STATE_UNQUOTED_VALUE: + case self::STATE_BEFORE_VALUE: + $owner = $this->attributeValueOwner($state, $attributeName, $pendingValueOwner); + + return [HtmlContext::KIND_ATTRIBUTE_VALUE, $this->normalizedAttributeName($owner), '']; + case self::STATE_BEFORE_ATTRIBUTE: + case self::STATE_AFTER_ATTRIBUTE: + $lookahead = $index; + + if ($this->startsEmbedded($markup, $lookahead)) { + $end = $this->embeddedEnd($markup, $lookahead, $length); + + if ($end === false) { + $lookahead = $length; + } else { + $lookahead = $end; + } + } + + while ($lookahead < $length && $this->isHtmlSpace($markup[$lookahead])) { + $lookahead++; + } + + if (($markup[$lookahead] ?? null) === '=' && $state === self::STATE_BEFORE_ATTRIBUTE) { + return [HtmlContext::KIND_ATTRIBUTE_NAME, null, '']; + } + + return [HtmlContext::KIND_TAG_OPEN, null, '']; + default: + return [HtmlContext::KIND_TAG_OPEN, null, '']; + } + } + + protected function stateAfterAntlers(string $state): string + { + return match ($state) { + self::STATE_BEFORE_VALUE => self::STATE_UNQUOTED_VALUE, + self::STATE_BEFORE_ATTRIBUTE, self::STATE_AFTER_ATTRIBUTE => self::STATE_ATTRIBUTE_NAME, + default => $state, + }; + } + + protected function normalizedAttributeName(string $name): ?string + { + if ($name === '') { + return null; + } + + return strtolower($name); + } + + protected function isClosingQuote(string $state, string $char): bool + { + if ($state === self::STATE_DOUBLE_QUOTED) { + return $char === '"'; + } + + return $state === self::STATE_SINGLE_QUOTED && $char === "'"; + } + + protected function attributeValueOwner(string $state, string $attributeName, string $pendingValueOwner): string + { + if ($state === self::STATE_BEFORE_VALUE && $pendingValueOwner) { + return $pendingValueOwner; + } + + if ($attributeName) { + return $attributeName; + } + + return $pendingValueOwner; + } + + public function hasAttribute(string $openingTag, string $attributeName): bool + { + if (stripos($openingTag, $attributeName) === false) { + return false; + } + + $length = strlen($openingTag); + $index = 1; + + while ($index < $length + && ! HtmlSpec::isTagNameDelimiter($openingTag[$index])) { + if ($this->startsEmbedded($openingTag, $index)) { + $end = $this->embeddedEnd($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 ($this->startsEmbedded($openingTag, $index)) { + $end = $this->embeddedEnd($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 ($this->startsEmbedded($openingTag, $index)) { + $dynamicName = true; + $end = $this->embeddedEnd($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 ($this->startsEmbedded($openingTag, $index)) { + $end = $this->embeddedEnd($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 ($this->startsEmbedded($openingTag, $index)) { + $end = $this->embeddedEnd($openingTag, $index, $length); + + if ($end === false) { + return false; + } + + $index = $end; + } else { + $index++; + } + } + } + } + + return false; + } + + protected function isHtmlSpace(string $char): bool + { + return HtmlSpec::isHtmlSpace($char); + } + + protected function startsEmbedded(string $content, int $index): bool + { + $char = $content[$index] ?? ''; + + if ($char === '{') { + return ($content[$index + 1] ?? '') === '{'; + } + + return $char === '<' && RegionExtents::startsPhpBlock($content, $index); + } + + protected function embeddedEnd(string $content, int $start, int $length): int|false + { + return RegionExtents::embeddedEndInBytes($content, $start, $length) ?? false; + } +} diff --git a/src/View/Instrumentation/Antlers/RegionExtents.php b/src/View/Instrumentation/Antlers/RegionExtents.php new file mode 100644 index 00000000000..25e919d5fed --- /dev/null +++ b/src/View/Instrumentation/Antlers/RegionExtents.php @@ -0,0 +1,114 @@ +') { + return $index + 2; + } + } + + return $length; + } + + public static function endInBytes(string $source, int $start, int $length): int|false + { + $terminator = match ($source[$start + 2] ?? '') { + '#' => '#}}', + '?' => '?}}', + '$' => '$}}', + default => null, + }; + + if ($terminator !== null) { + $end = strpos($source, $terminator, $start + 3); + + return $end === false ? false : $end + 3; + } + + $index = $start + 2; + + while ($index < $length) { + $char = $source[$index]; + $previous = $source[$index - 1]; + + if ($char === '{' && $previous !== '@') { + $depth = 0; + + while ($index < $length) { + $char = $source[$index]; + + if ($source[$index - 1] !== '@') { + if ($char === '{') { + $depth++; + } elseif ($char === '}') { + $depth--; + + if ($depth === 0) { + break; + } + } + } + + $index++; + } + + $index++; + + continue; + } + + if ($char === '}' && $previous !== '@' && ($source[$index + 1] ?? null) === '}') { + return $index + 2; + } + + $index++; + } + + return false; + } +} diff --git a/src/View/Instrumentation/Antlers/TemplateAnalyzer.php b/src/View/Instrumentation/Antlers/TemplateAnalyzer.php new file mode 100644 index 00000000000..ba3298c7883 --- /dev/null +++ b/src/View/Instrumentation/Antlers/TemplateAnalyzer.php @@ -0,0 +1,236 @@ +inspector = new ElementInspector; + $this->componentPrefixes = $componentPrefixes ?? ComponentPrefixes::DEFAULTS; + } + + /** + * @return array{regions: TemplateRegion[], elements: array, contexts: array} + */ + public function analyze(string $template, bool $collectElements = true, bool $commentsAtDocumentRoot = true, ?ComponentSourceMap $sourceMap = null): array + { + $parser = new DocumentParser; + $parser->inheritRuntimeLineSeed(false); + $parser->parse($template); + $nodes = $parser->getNodes(); + $lineOffset = InstrumentationState::lineSeed() - 1; + $characterOffset = InstrumentationState::offsetSeed(); + + (new ContextScanner) + ->treatComponentsAsDynamic() + ->withComponentPrefixes($this->componentPrefixes) + ->annotate($nodes, $parser); + + $pending = []; + $characterOffsets = []; + $contexts = []; + + foreach ($nodes as $node) { + if ($node instanceof AntlersNode) { + $contexts[] = $node->htmlContext; + } + + 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' => [], 'contexts' => $contexts]; + } + + [$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]) { + $insideCharacterReference = $context->isSafeForHtmlComments() + && $this->continuesCharacterReference($template, $byteOffsets[$start]); + + $regions[] = new TemplateRegion( + Span::ENGINE_ANTLERS, + $this->regionType($isPaired), + $this->regionExpression($node), + ($mappedStarts[$start]['line'] ?? $node->startPosition->line) + $lineOffset, + ($mappedStarts[$start]['offset'] ?? $sourceCharacterOffsets[$byteOffsets[$start]]) + $characterOffset, + ($mappedEnds[$end]['offset'] ?? $sourceCharacterOffsets[$byteOffsets[$end]]) + $characterOffset, + $byteOffsets[$start], + $byteOffsets[$end], + $context, + $commentSafe && ! $insideCharacterReference, + $insideCharacterReference + ); + + if ($this->canCollectElement($collectElements, $context) + && ! isset($elements[$context->elementStartOffset])) { + $elements[$context->elementStartOffset] = $this->elementFacts( + $template, + $context, + $byteOffsets[$context->elementStartOffset] + ); + } + } + + return ['regions' => $regions, 'elements' => $elements, 'contexts' => $contexts]; + } + + protected function continuesCharacterReference(string $template, int $byteStart): bool + { + $length = min($byteStart, 34); + + if ($length === 0) { + return false; + } + + return preg_match('/&#?[A-Za-z0-9]*$/D', substr($template, $byteStart - $length, $length)) === 1; + } + + protected function shouldSkipNode(AbstractNode $node): bool + { + 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(bool $collectElements, HtmlContext $context): bool + { + if (! $collectElements) { + return false; + } + + if ($context->elementStartOffset === null) { + return false; + } + + return $context->canInstrumentElement(); + } + + protected function regionExpression(AntlersNode $node): string + { + if ($node->interpolationRegions === []) { + return trim($node->content); + } + + $raw = $node->getNodeDocumentText(); + $inner = substr($raw, strlen($node->rawStart), strlen($raw) - strlen($node->rawStart) - strlen($node->rawEnd)); + + return trim($inner === '' ? $node->content : $inner); + } + + protected function regionType(bool $isPaired): string + { + if ($isPaired) { + return TemplateRegion::TYPE_PAIR; + } + + return TemplateRegion::TYPE_SINGLE; + } + + protected function isComponentProxyNode(AntlersNode $node): bool + { + return ComponentCompiler::isComponentProxyContent((string) $node->content); + } + + /** @return array{insertAt: int, hasAttribute: callable} */ + protected function elementFacts(string $template, HtmlContext $context, int $byteStart): array + { + $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), + ]; + } + + protected function closingBoundaryFor(AntlersNode $node): ?AntlersNode + { + $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..21d7f455b2c --- /dev/null +++ b/src/View/Instrumentation/ComponentPrefixes.php @@ -0,0 +1,44 @@ +commentPrefix = $prefix; + $this->commentsEnabled = true; + $this->flushResultCache(); + + return $this; + } + + public function withoutComments(): static + { + $this->commentsEnabled = false; + $this->flushResultCache(); + + return $this; + } + + public function markersUsing(callable $factory): static + { + $this->commentFactory = Closure::fromCallable($factory); + $this->flushResultCache(); + + return $this; + } + + public function filter(callable $filter): static + { + $filter = Closure::fromCallable($filter); + $previous = $this->nodeFilter; + + $this->nodeFilter = $previous === null + ? $filter + : static fn ($metadata, $region) => $previous($metadata, $region) && $filter($metadata, $region); + + $this->flushResultCache(); + + return $this; + } + + /** @param string|array ...$patterns */ + public function only(string|array ...$patterns): static + { + $patterns = $this->expressionPatterns($patterns); + + return $this->filter(static fn ($metadata) => Str::is($patterns, $metadata['expression'])); + } + + /** @param string|array ...$patterns */ + public function except(string|array ...$patterns): static + { + $patterns = $this->expressionPatterns($patterns); + + return $this->filter(static fn ($metadata) => ! Str::is($patterns, $metadata['expression'])); + } + + /** + * @param array> $patterns + * @return string[] + */ + protected function expressionPatterns(array $patterns): array + { + return array_values(array_filter(Arr::flatten($patterns), 'is_string')); + } + + public function onSkip(callable $callback): static + { + $this->skipCallback = Closure::fromCallable($callback); + $this->flushResultCache(); + + return $this; + } + + public function attributes(string ...$attributeNames): static + { + foreach ($attributeNames ?: ['data-antlers'] as $attributeName) { + $this->attribute($attributeName); + } + + return $this; + } + + public function attribute(string $attributeName, ?callable $valueFactory = null): static + { + 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] = Closure::fromCallable($valueFactory); + $this->flushResultCache(); + + return $this; + } + + protected function invalidInstrumentationAttributeName(string $attributeName): bool + { + return ! HtmlSpec::isValidName($attributeName); + } + + public function jsonAttribute(string $attributeName = 'data-antlers'): static + { + 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 string|array ...$prefixes */ + public function componentPrefixes(string|array ...$prefixes): static + { + $this->componentPrefixes = ComponentPrefixes::merge(Arr::flatten($prefixes)); + $this->flushResultCache(); + + return $this; + } + + /** @param array $metadata */ + public function withMetadata(array $metadata): static + { + $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..563ffb41821 --- /dev/null +++ b/src/View/Instrumentation/Concerns/EmitsInstrumentation.php @@ -0,0 +1,308 @@ +} $analysis */ + protected function emitInstrumentation(string $template, array $analysis): string + { + $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, 'content' => $start, 'type' => 'start', 'nodeStart' => $region->byteStart]; + } + + if ($end !== '') { + $edits[] = ['start' => $region->byteEnd, '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'], + '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 */ + protected function reportDeferredSkips(array $entries): void + { + foreach ($entries as [$metadata, $region, $alreadyContributed]) { + if (! $alreadyContributed) { + $this->reportSkip($metadata, $region, $this->skipReasonFor($region)); + } + } + } + + /** @return array */ + protected function regionMetadata(TemplateRegion $region): array + { + $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 */ + protected function reportSkip(array $metadata, TemplateRegion $region, string $reason): void + { + if ($this->skipCallback !== null) { + call_user_func($this->skipCallback, $metadata, $region, $reason); + } + } + + protected function skipReasonFor(TemplateRegion $region): string + { + $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_PHP) { + return self::SKIP_INSIDE_PHP; + } + + 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 ($region->insideCharacterReference) { + return self::SKIP_INSIDE_CHARACTER_REFERENCE; + } + + 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): array + { + $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 !== '' && InstrumentationState::attributesView()) { + $metadata['view'] = $this->publicViewIdentifier($view); + } + + return array_merge($metadata, $this->extraMetadata); + } + + protected function publicViewIdentifier(string $view): string + { + $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): array + { + 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 array $edits */ + protected function spliceByteEdits(string $template, array $edits): string + { + if (empty($edits)) { + return $template; + } + + $order = ['end' => 0, 'attribute' => 1, 'start' => 2]; + + 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') { + 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']); + } + + 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..11640297d2c --- /dev/null +++ b/src/View/Instrumentation/Concerns/MemoizesResults.php @@ -0,0 +1,330 @@ +configurationRevision++; + $this->cacheScope = null; + } + + /** @return array|null */ + public static function annotationsFor(string $instrumented): ?array + { + if (! static::$transfersContexts || static::$annotations === []) { + return null; + } + + return static::$annotations[hash('xxh128', $instrumented)] ?? null; + } + + public static function transferContexts(bool $enabled = true): void + { + static::$transfersContexts = $enabled; + } + + public static function flushAnnotations(): void + { + static::$annotations = []; + } + + public function lastCacheStoreException(): ?Throwable + { + return $this->lastCacheStoreException; + } + + protected function cachedResult(string $key, bool $persistent = true): ?string + { + $entry = static::$results[$this->memoKey($key)] ?? null; + + if ($entry === null && $persistent && ($entry = $this->storedEntry($key)) !== null) { + $this->memoize($key, $entry); + } + + if ($entry === null) { + return null; + } + + static::rememberAnnotations($entry[2], $entry[1]); + + return $entry[0]; + } + + /** @param array|null $contexts */ + protected function storeResult(string $key, string $result, ?array $contexts = null, bool $persistent = true): string + { + $entry = [$result, $contexts, hash('xxh128', $result)]; + + $this->memoize($key, $entry); + + if ($persistent) { + $this->persistEntry($key, $entry); + } + + static::rememberAnnotations($entry[2], $contexts); + + return $result; + } + + protected function memoKey(string $key): string + { + return $this->cacheFingerprint().':'.$key; + } + + /** @param array{0: string, 1: array|null, 2: string} $entry */ + protected function memoize(string $key, array $entry): void + { + if (count(static::$results) >= static::$resultLimit) { + static::$results = array_slice(static::$results, intdiv(static::$resultLimit, 2), null, true); + } + + static::$results[$this->memoKey($key)] = $entry; + } + + /** @param array|null $contexts */ + protected static function rememberAnnotations(string $annotationKey, ?array $contexts): void + { + if ($contexts === null || isset(static::$annotations[$annotationKey])) { + return; + } + + if (count(static::$annotations) >= static::$annotationLimit) { + static::$annotations = array_slice(static::$annotations, intdiv(static::$annotationLimit, 2), null, true); + } + + static::$annotations[$annotationKey] = $contexts; + } + + protected function usesCacheStore(): bool + { + return $this->cacheScope !== null + && $this->cacheStore !== false + && ! $this->cacheStoreFailed; + } + + protected function cacheRepository(): Repository + { + return Cache::store(is_string($this->cacheStore) ? $this->cacheStore : null); + } + + protected function cacheStoreKey(string $key): string + { + return 'statamic.antlers.instrumentation.'.static::cacheStoreNamespace().'.'.$this->cacheScope.'.'.hash('xxh128', $key); + } + + protected static function cacheStoreNamespace(): string + { + return static::$cacheStoreNamespace ??= 'v'.static::CACHE_FORMAT.'.'.hash('xxh64', static::installedVersion()); + } + + protected static function installedVersion(): string + { + try { + if (InstalledVersions::isInstalled('statamic/cms')) { + $version = InstalledVersions::getVersion('statamic/cms').'@'.InstalledVersions::getReference('statamic/cms'); + } else { + $root = InstalledVersions::getRootPackage(); + $version = $root['version'].'@'.$root['reference']; + } + } catch (Throwable) { + $version = 'unknown@'; + } + + if (str_contains($version, 'dev') || str_ends_with($version, '@')) { + $version .= ':'.static::sourceStamp(); + } + + return $version; + } + + protected static function sourceStamp(): string + { + $newestModification = 0; + + foreach (static::analysisSourceDirectories() as $directory) { + foreach (glob($directory.'/*.php') ?: [] as $file) { + $newestModification = max($newestModification, (int) filemtime($file)); + } + } + + return (string) $newestModification; + } + + /** @return string[] */ + protected static function analysisSourceDirectories(): array + { + return [ + __DIR__.'/..', + __DIR__, + __DIR__.'/../Antlers', + __DIR__.'/../../Antlers/Language/Analyzers/Html', + __DIR__.'/../../Antlers/Language/Analyzers/Html/Concerns', + __DIR__.'/../../Antlers/Language/Parser', + __DIR__.'/../../Antlers/Language/Parser/Concerns', + __DIR__.'/../../Antlers/Language/Nodes', + __DIR__.'/../../Antlers/Language/Utilities', + ]; + } + + /** @return array{0: string, 1: array|null, 2: string}|null */ + protected function storedEntry(string $key): ?array + { + if (! $this->usesCacheStore()) { + return null; + } + + try { + $stored = $this->cacheRepository()->get($this->cacheStoreKey($key)); + } catch (Throwable $exception) { + $this->failCacheStore($exception); + + return null; + } + + if (! is_array($stored) || ! is_string($stored[0] ?? null)) { + return null; + } + + $contexts = null; + + if (($stored[1] ?? null) !== null || ($stored[2] ?? null) !== null) { + $contexts = static::unpackContexts($stored[1] ?? null, $stored[2] ?? null); + + if ($contexts === null) { + return null; + } + } + + return [$stored[0], $contexts, hash('xxh128', $stored[0])]; + } + + /** @param array{0: string, 1: array|null, 2: string} $entry */ + protected function persistEntry(string $key, array $entry): void + { + if (! $this->usesCacheStore()) { + return; + } + + [$stacks, $packed] = static::packContexts($entry[1]); + + try { + $this->cacheRepository()->put($this->cacheStoreKey($key), [$entry[0], $stacks, $packed], static::CACHE_TTL); + } catch (Throwable $exception) { + $this->failCacheStore($exception); + } + } + + protected function failCacheStore(Throwable $exception): void + { + $this->lastCacheStoreException = $exception; + $this->cacheStoreFailed = true; + } + + /** + * @param array|null $contexts + * @return array{0: array|null, 1: array|null} + */ + protected static function packContexts(?array $contexts): array + { + if ($contexts === null) { + return [null, null]; + } + + $stacks = []; + $indexes = []; + $packed = []; + + foreach ($contexts as $context) { + if ($context === null) { + $packed[] = null; + + continue; + } + + $entry = $context->toArray(); + $stackKey = implode('/', $entry[3]); + + if (! isset($indexes[$stackKey])) { + $indexes[$stackKey] = count($stacks); + $stacks[] = $entry[3]; + } + + $entry[3] = $indexes[$stackKey]; + $packed[] = $entry; + } + + return [$stacks, $packed]; + } + + /** @return array|null */ + protected static function unpackContexts(mixed $stacks, mixed $packed): ?array + { + if (! is_array($stacks) || ! is_array($packed)) { + return null; + } + + $contexts = []; + + try { + foreach ($packed as $entry) { + if ($entry === null) { + $contexts[] = null; + + continue; + } + + if (! static::isPackedContext($entry, $stacks)) { + return null; + } + + $entry[3] = $stacks[$entry[3]]; + $contexts[] = HtmlContext::fromArray($entry); + } + } catch (Throwable) { + return null; + } + + return $contexts; + } + + /** @param array $stacks */ + protected static function isPackedContext(mixed $entry, array $stacks): bool + { + if (! is_array($entry) || ! array_is_list($entry) || count($entry) !== 6) { + return false; + } + + [$kind, $elementName, $attributeName, $stack, $elementStartOffset, $flags] = $entry; + + if (! is_string($kind) || ! is_int($stack) || ! is_int($flags)) { + return false; + } + + if (($elementName !== null && ! is_string($elementName)) + || ($attributeName !== null && ! is_string($attributeName)) + || ($elementStartOffset !== null && ! is_int($elementStartOffset))) { + return false; + } + + if (! isset($stacks[$stack]) || ! is_array($stacks[$stack]) || ! array_is_list($stacks[$stack])) { + return false; + } + + foreach ($stacks[$stack] as $name) { + if (! is_string($name)) { + return false; + } + } + + return true; + } +} diff --git a/src/View/Instrumentation/Concerns/RemovesMisplacedMarkers.php b/src/View/Instrumentation/Concerns/RemovesMisplacedMarkers.php new file mode 100644 index 00000000000..6c18fbfaae4 --- /dev/null +++ b/src/View/Instrumentation/Concerns/RemovesMisplacedMarkers.php @@ -0,0 +1,186 @@ +') || ($start = strrpos($html, ''); + } + + $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(string $template, ?string $view, bool $commentsAtDocumentRoot): string + { + return ($commentsAtDocumentRoot ? 'root:' : 'nested:') + .InstrumentationState::lineSeed() + .':'.InstrumentationState::offsetSeed() + .':'.(InstrumentationState::attributesView() ? '1' : '0') + .':'.(InstrumentationState::sourceMap()?->fingerprint() ?? '') + .':'.($view ?? '') + .':'.hash('xxh128', $template); + } +} diff --git a/src/View/Instrumentation/HtmlSpec.php b/src/View/Instrumentation/HtmlSpec.php new file mode 100644 index 00000000000..a93c894cb8f --- /dev/null +++ b/src/View/Instrumentation/HtmlSpec.php @@ -0,0 +1,323 @@ +]|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, + ]; + + public static function isHtmlSpace(string $char): bool + { + return $char === ' ' || $char === "\t" || $char === "\n" || $char === "\r" || $char === "\f"; + } + + public static function isTagNameDelimiter(?string $char): bool + { + return $char === null || self::isHtmlSpace($char) || $char === '>' || $char === '/'; + } + + public static function isAttributeNameDelimiter(?string $char): bool + { + return self::isTagNameDelimiter($char) || $char === '='; + } + + public static function isValidName(mixed $name): bool + { + 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); + } + + public static function isAsciiAlpha(?string $char): bool + { + if ($char === null) { + return false; + } + + if ($char >= 'A' && $char <= 'Z') { + return true; + } + + return $char >= 'a' && $char <= 'z'; + } + + /** + * @param array $dynamicDelimiters + * @return string[] + */ + public static function classList(mixed $value, array $dynamicDelimiters = [['{{', '}}']]): array + { + $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 IMPLIED_END_TAGS = [ + 'dd' => true, 'dt' => true, 'li' => true, 'optgroup' => true, + 'option' => true, 'p' => true, '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_CELL_ANCESTORS = [ + 'thead' => true, 'tbody' => true, 'tfoot' => true, 'tr' => true, 'colgroup' => 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..292356373a2 --- /dev/null +++ b/src/View/Instrumentation/InstrumentationManager.php @@ -0,0 +1,570 @@ + */ + protected WeakMap $runtimeConfigurations; + + /** @var array */ + protected array $configured = []; + + /** @var array */ + protected array $antlersPieces = []; + + /** @var array{0: mixed, 1: HtmlInstrumentation|null}|null */ + protected ?array $configuredState = null; + + /** @var HtmlInstrumentation[]|null */ + protected ?array $htmlInstrumenters = null; + + /** @var string[] */ + protected array $cleanedWith = []; + + protected ?string $cleanedHash = null; + + protected int $cleanedLength = -1; + + protected int $misplacedMarkerRemovals = 0; + + protected int $removedMisplacedMarkerBytes = 0; + + /** @var callable[] */ + protected array $runtimePreparsers = []; + + /** @var NodeVisitorContract[] */ + protected array $runtimeVisitors = []; + + public function __construct() + { + $this->runtimeConfigurations = new WeakMap; + } + + public function preparse(callable $preparser): static + { + if (! in_array($preparser, $this->runtimePreparsers, true)) { + $this->runtimePreparsers[] = $preparser; + } + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->preparse($preparser)); + + return $this; + } + + public function removePreparser(callable $preparser): static + { + $this->runtimePreparsers = $this->without($this->runtimePreparsers, $preparser); + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removePreparser($preparser)); + + return $this; + } + + public function addVisitor(NodeVisitorContract $visitor): static + { + if (! in_array($visitor, $this->runtimeVisitors, true)) { + $this->runtimeVisitors[] = $visitor; + RuntimeParser::clearRenderNodeCache(); + } + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->addVisitor($visitor)); + + return $this; + } + + public function removeVisitor(NodeVisitorContract $visitor): static + { + if (in_array($visitor, $this->runtimeVisitors, true)) { + $this->runtimeVisitors = $this->without($this->runtimeVisitors, $visitor); + RuntimeParser::clearRenderNodeCache(); + } + + $this->applyToAntlers(fn (RuntimeConfiguration $config) => $config->removeVisitor($visitor)); + + return $this; + } + + /** @param string[]|null $engines */ + public function register(object|string $piece, ?array $engines = null): static + { + $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; + } + + /** + * @template TReturn + * + * @param callable(): TReturn $callback + * @param string[]|null $engines + * @return TReturn + */ + public function using(object|string $piece, callable $callback, ?array $engines = null): mixed + { + if (is_string($piece)) { + $piece = $this->configured[$piece] ?? app($piece); + } + + $registered = in_array($piece, $this->antlersPieces, true); + + $this->register($piece, $engines); + + try { + return $callback(); + } finally { + if (! $registered) { + $this->forget($piece, $engines); + } + } + } + + /** @return Collection> */ + public function regions(string $html): Collection + { + return HtmlInstrumentation::regionsIn($html, $this->htmlInstrumenters()); + } + + /** @param array $config */ + public function configuredHtmlInstrumentation(array $config): HtmlInstrumentation + { + $key = $this->configurationKey($config); + + if ($key === null) { + return HtmlInstrumentation::fromConfig($config); + } + + if (isset($this->configured[$key])) { + return $this->configured[$key]; + } + + foreach (array_keys($this->configured) as $configured) { + if (str_starts_with($configured, 'html:')) { + unset($this->configured[$configured]); + } + } + + return $this->configured[$key] = HtmlInstrumentation::fromConfig($config); + } + + /** @param array $config */ + protected function configurationKey(array $config): ?string + { + $fingerprint = HtmlInstrumentation::configurationFingerprint($config); + + return $fingerprint === null ? null : 'html:'.$fingerprint; + } + + /** @param string[]|null $engines */ + public function forget(object|string $piece, ?array $engines = null): static + { + $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 && in_array($piece, $this->antlersPieces, true)) { + $this->antlersPieces = $this->without($this->antlersPieces, $piece); + $this->htmlInstrumenters = null; + + $this->removeFromAntlers($piece); + } + + return $this; + } + + public function flush(): static + { + $antlers = $this->antlersPieces; + $preparsers = $this->runtimePreparsers; + $visitors = $this->runtimeVisitors; + + $this->configured = []; + $this->antlersPieces = []; + $this->runtimePreparsers = []; + $this->runtimeVisitors = []; + $this->htmlInstrumenters = null; + $this->cleanedWith = []; + $this->cleanedHash = null; + + foreach ($antlers as $piece) { + $this->removeFromAntlers($piece); + } + + if ($visitors !== []) { + RuntimeParser::clearRenderNodeCache(); + } + + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($preparsers, $visitors) { + foreach ($preparsers as $preparser) { + $config->removePreparser($preparser); + } + + foreach ($visitors as $visitor) { + $config->removeVisitor($visitor); + } + }); + + return $this; + } + + protected function removeFromAntlers(object $piece): void + { + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($piece) { + if ($piece instanceof HtmlInstrumentation) { + $config->removePreparser($piece); + + return; + } + + $config->traceManager?->removeTracer($piece); + }, ! $piece instanceof HtmlInstrumentation); + } + + protected function rememberAntlersPiece(HtmlInstrumentation|RuntimeTracerContract|TracerContract $piece): void + { + if (! in_array($piece, $this->antlersPieces, true)) { + $this->antlersPieces[] = $piece; + $this->htmlInstrumenters = null; + } + } + + /** + * @template T + * + * @param T[] $items + * @return T[] + */ + protected function without(array $items, mixed $needle): array + { + return array_values(array_filter($items, fn ($item) => $item !== $needle)); + } + + public function applyTo(RuntimeConfiguration $runtimeConfiguration): void + { + $this->runtimeConfigurations[$runtimeConfiguration] ??= $runtimeConfiguration->isTracingEnabled; + + if (($configured = $this->configuredInstrumenter()) !== null) { + $runtimeConfiguration->preparse($configured); + } + + 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); + } + } + + public function sync(): static + { + $this->configuredInstrumenter(); + + return $this; + } + + public function instrumenting(): bool + { + return $this->htmlInstrumenters() !== []; + } + + public function removeMarkers(string $html): string + { + return HtmlInstrumentation::removeMarkers($html, $this->htmlInstrumenters()); + } + + public function containsMarkers(string $html): bool + { + return HtmlInstrumentation::containsMarkers($html, $this->htmlInstrumenters()); + } + + public function tagBody(AntlersNode $node, bool $composesOutput = false): TagBody + { + $instrumenters = $this->htmlInstrumenters(); + $key = $this->tagBodyKey($instrumenters, $composesOutput); + + if ($node->tagBody !== null && $node->tagBody[0] === $key) { + return $node->tagBody[1]; + } + + $body = TagBody::from((string) $node->runtimeContent, $instrumenters, $composesOutput); + $node->tagBody = [$key, $body]; + + return $body; + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function tagBodyKey(array $instrumenters, bool $composesOutput): string + { + $mode = $composesOutput ? 'composes' : 'clean'; + + return $mode.':'.implode("\0", $this->fingerprints($instrumenters)); + } + + /** @param HtmlInstrumentation[]|null $instrumenters */ + public function removeMisplacedMarkers(string $html, ?array $instrumenters = null): string + { + $instrumenters ??= $this->htmlInstrumenters(); + + if ($instrumenters === []) { + return $html; + } + + if (strlen($html) === $this->cleanedLength + && $this->cleanedCovers($instrumenters) + && hash('xxh3', $html) === $this->cleanedHash) { + return $html; + } + + $cleaned = $this->cleanMisplacedMarkers($html, $instrumenters); + + if ($cleaned !== $html) { + $this->misplacedMarkerRemovals++; + $this->removedMisplacedMarkerBytes += strlen($html) - strlen($cleaned); + } + + $this->cleanedWith = $this->fingerprints($instrumenters); + $this->cleanedLength = strlen($cleaned); + $this->cleanedHash = hash('xxh3', $cleaned); + + return $cleaned; + } + + public function misplacedMarkerRemovals(): int + { + return $this->misplacedMarkerRemovals; + } + + public function removedMisplacedMarkerBytes(): int + { + return $this->removedMisplacedMarkerBytes; + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function cleanedCovers(array $instrumenters): bool + { + return array_diff($this->fingerprints($instrumenters), $this->cleanedWith) === []; + } + + /** + * @param HtmlInstrumentation[] $instrumenters + * @return string[] + */ + protected function fingerprints(array $instrumenters): array + { + return array_map( + fn (HtmlInstrumentation $instrumenter) => $instrumenter->cacheFingerprint(), + $instrumenters + ); + } + + /** @param HtmlInstrumentation[] $instrumenters */ + protected function cleanMisplacedMarkers(string $html, array $instrumenters): string + { + return HtmlInstrumentation::removeMisplacedMarkers($html, $instrumenters); + } + + public function trimAroundMarkers(string $html): string + { + return HtmlInstrumentation::trimAroundMarkers($html, $this->htmlInstrumenters()); + } + + /** @return array{0: string, 1: string|null} */ + public function splitMarkers(string $output): array + { + $instrumenters = $this->htmlInstrumenters(); + + if ($instrumenters === [] || ! HtmlInstrumentation::containsMarkers($output, $instrumenters)) { + return [trim($output), null]; + } + + $instrumented = HtmlInstrumentation::trimAroundMarkers($output, $instrumenters); + $stripped = HtmlInstrumentation::removeMarkers($instrumented, $instrumenters); + $value = trim($stripped); + + if ($instrumented === $value || $stripped !== $value) { + return [$value, null]; + } + + return [$value, $instrumented]; + } + + /** @return HtmlInstrumentation[] */ + protected function htmlInstrumenters(): array + { + $configured = $this->configuredInstrumenter(); + + if ($this->htmlInstrumenters !== null) { + return $this->htmlInstrumenters; + } + + $instrumenters = array_values(array_filter( + $this->antlersPieces, + fn ($piece) => $piece instanceof HtmlInstrumentation + )); + + if ($configured !== null) { + $instrumenters[] = $configured; + } + + return $this->htmlInstrumenters = $instrumenters; + } + + protected function configuredInstrumenter(): ?HtmlInstrumentation + { + $config = Config::all()['statamic']['antlers']['instrumentation'] ?? []; + + if ($this->configuredState !== null && $this->configuredState[0] === $config) { + return $this->configuredState[1]; + } + + $instrumenter = null; + + if (is_array($config) && ($config['enabled'] ?? false)) { + $instrumenter = $this->configuredHtmlInstrumentation( + array_intersect_key($config, array_flip(['comments', 'prefix', 'attributes', 'metadata', 'componentPrefixes', 'cache'])) + ); + } + + $previous = $this->configuredState[1] ?? null; + $this->configuredState = [$config, $instrumenter]; + + if ($previous !== $instrumenter) { + $this->htmlInstrumenters = null; + + $this->applyToAntlers( + fn (RuntimeConfiguration $runtimeConfiguration) => $runtimeConfiguration->replacePreparser($previous, $instrumenter) + ); + } + + return $instrumenter; + } + + /** @param string[]|null $engines */ + protected function registerTracer(RuntimeTracerContract|TracerContract $tracer, ?array $engines): static + { + $engines ??= [static::ENGINE_ANTLERS]; + + if (in_array(static::ENGINE_ANTLERS, $engines, true)) { + $this->rememberAntlersPiece($tracer); + + $this->applyToAntlers(function (RuntimeConfiguration $config) use ($tracer) { + $config->enableTracing()->registerTracer($tracer); + }, true); + } + + return $this; + } + + /** @param string[]|null $engines */ + protected function registerHtmlInstrumentation(HtmlInstrumentation $instrumentation, ?array $engines): static + { + $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; + } + + protected function applyToAntlers(callable $apply, bool $syncTracing = false): void + { + foreach ($this->runtimeConfigurations as $configuration => $tracingEnabled) { + $apply($configuration); + + if ($syncTracing) { + $configuration->isTracingEnabled = $tracingEnabled || $configuration->hasRegisteredTracers(); + } + } + } + + /** + * @param string[]|null $engines + * @return string[]|null + */ + protected function normalizeEngines(?array $engines): ?array + { + 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..03ffcf781bc --- /dev/null +++ b/src/View/Instrumentation/InstrumentationState.php @@ -0,0 +1,191 @@ + */ + protected static array $outputContexts = []; + + public static function twinsKey(): string + { + if (self::$twinsKey === null || self::$twinsEnvironment !== GlobalRuntimeState::$environmentId) { + self::$twinsEnvironment = GlobalRuntimeState::$environmentId; + self::$twinsKey = GlobalRuntimeState::createIndicatorVariable(self::INDICATOR_TWINS); + } + + return self::$twinsKey; + } + + public static function sourceMap(): ?ComponentSourceMap + { + return self::$sourceMap; + } + + public static function lineSeed(): int + { + return self::$lineSeed; + } + + public static function offsetSeed(): int + { + return self::$offsetSeed; + } + + public static function attributesView(): bool + { + return self::$attributesView; + } + + /** @param array{0: int, 1: int, 2: bool} $seed */ + public static function withSourceSeed(array $seed, callable $callback): mixed + { + $previous = [self::$lineSeed, self::$offsetSeed, self::$attributesView]; + [self::$lineSeed, self::$offsetSeed, self::$attributesView] = $seed; + + try { + return $callback(); + } finally { + [self::$lineSeed, self::$offsetSeed, self::$attributesView] = $previous; + } + } + + public static function withSourceMap(?ComponentSourceMap $sourceMap, callable $callback): mixed + { + $previous = self::$sourceMap; + self::$sourceMap = $sourceMap; + + try { + return $callback(); + } finally { + self::$sourceMap = $previous; + } + } + + public static function inComponentView(): bool + { + return self::$componentViewDepth > 0; + } + + public static function instrumenting(): bool + { + return self::$viewInstrumentationDepth > 0; + } + + public static function suppressed(): bool + { + return self::$suppressHtmlInstrumentation; + } + + public static function withoutInstrumentation(callable $callback): mixed + { + $previous = self::$suppressHtmlInstrumentation; + self::$suppressHtmlInstrumentation = true; + + try { + return $callback(); + } finally { + self::$suppressHtmlInstrumentation = $previous; + } + } + + public static function withinComponentView(callable $callback): mixed + { + self::$componentViewDepth++; + + try { + return $callback(); + } finally { + self::$componentViewDepth--; + } + } + + public static function whileInstrumenting(callable $callback): mixed + { + self::$viewInstrumentationDepth++; + + try { + return $callback(); + } finally { + self::$viewInstrumentationDepth--; + } + } + + public static function bufferedOutputIsCommentSafe(): bool + { + $level = ob_get_level(); + + if ($level === 0) { + return true; + } + + $length = ob_get_length(); + + if ($length === false || $length === 0) { + unset(self::$outputContexts[$level]); + + return true; + } + + $buffered = ob_get_contents(); + [$context, $scannedHash] = self::$outputContexts[$level] ?? [null, '']; + + if ($context === null + || $context->length() > $length + || hash('xxh3', substr($buffered, 0, $context->length())) !== $scannedHash) { + $context = new OutputContext; + } + + $context->advance($buffered); + self::$outputContexts[$level] = [$context, hash('xxh3', substr($buffered, 0, $context->length()))]; + + return $context->isCommentSafe(); + } + + public static function activeTagBody(): ?TagBody + { + return self::$tagBodies === [] ? null : self::$tagBodies[count(self::$tagBodies) - 1]; + } + + public static function withTagBody(?TagBody $body, callable $callback): mixed + { + if ($body === null) { + return $callback(); + } + + self::$tagBodies[] = $body; + + try { + return $callback(); + } finally { + array_pop(self::$tagBodies); + } + } +} diff --git a/src/View/Instrumentation/MisplacedMarkerRemover.php b/src/View/Instrumentation/MisplacedMarkerRemover.php new file mode 100644 index 00000000000..f227bf84ae0 --- /dev/null +++ b/src/View/Instrumentation/MisplacedMarkerRemover.php @@ -0,0 +1,432 @@ + */ + private static array $compiled = []; + + private readonly int $length; + + private ?string $safeMarkup; + + /** @var string[] */ + private readonly array $literals; + + private readonly string $markerAt; + + private readonly string $anyMarker; + + private readonly string $commentEndOrMarker; + + private readonly string $bogusEndOrMarker; + + private readonly string $cdataEndOrMarker; + + /** @var array */ + private array $nextLiteral = []; + + private string $result = ''; + + private int $copied = 0; + + private int $index = 0; + + /** @param HtmlInstrumentation[] $instrumenters */ + private function __construct(private readonly string $html, string $markers, array $instrumenters) + { + [ + $this->safeMarkup, + $this->literals, + $this->markerAt, + $this->anyMarker, + $this->commentEndOrMarker, + $this->bogusEndOrMarker, + $this->cdataEndOrMarker, + ] = self::$compiled[$markers] ?? self::compile($markers, $instrumenters); + + $this->length = strlen($html); + } + + /** @param HtmlInstrumentation[] $instrumenters */ + public static function remove(string $html, string $markers, array $instrumenters): string + { + $cleaned = (new self($html, $markers, $instrumenters))->run(); + + while ($cleaned !== $html) { + $html = $cleaned; + $cleaned = (new self($html, $markers, $instrumenters))->run(); + } + + return $cleaned; + } + + public static function commentPattern(string $prefix): string + { + return ''; + } + + public static function attributePattern(string $name): string + { + return ' '.preg_quote($name, '/').'="[^"]*"'; + } + + private function run(): string + { + while ($this->index < $this->length) { + $this->skipSafeMarkup(); + + if ($this->index >= $this->length) { + break; + } + + if (($next = strpos($this->html, '<', $this->index)) === false) { + break; + } + + $this->index = $next; + + if (substr_compare($this->html, ''], ['class="b"', '{{ y }}', '!'], $template), + $document->toHtml() + ); + } + + public static function lineEndingProvider() + { + return [ + 'lf' => ["
\n{{ x }}

\n
\n"], + 'crlf' => ["
\r\n{{ x }}

\r\n
\r\n"], + 'cr' => ["
\r{{ x }}

\r
\r"], + 'mixed' => ["
\r\n{{ x }}

\n
\r\n\r"], + 'utf8 with crlf' => ["
\r\n

café ☕ {{ x }}

\r\n
\r\n"], + 'utf8 with mixed endings' => ["
\r

☕ {{ x }}

\r\n
\n"], + ]; + } + + public function test_inserted_text_adopts_the_line_ending_only_when_the_document_uses_one_uniformly() + { + [$parser, $nodes] = $this->parseHtml("
\r\n

{{ x }}

\n
"); + $nodes[0]->parentElement()->after("\ny\n")->setAttribute('title', "a\nb"); + + $this->assertSame("
\r\n

{{ x }}

\ny\n\n
", $parser->html()->toHtml()); + + [$parser, $nodes] = $this->parseHtml("
\r

{{ x }}

\r
"); + $nodes[0]->parentElement()->after("\ny\r\n"); + + $this->assertSame("
\r

{{ x }}

\ry\r\r
", $parser->html()->toHtml()); + } + + public function test_removing_attributes_absorbs_their_whitespace() + { + $remove = function ($template, ...$names) { + [$parser] = $this->parseHtml($template); + $element = $parser->html()->first(); + + foreach ($names as $name) { + $element->removeAttribute($name); + } + + return $parser->html()->toHtml(); + }; + + $this->assertSame('
x
', $remove('
x
', 'class')); + $this->assertSame('
x
', $remove('
x
', 'id')); + $this->assertSame('
x
', $remove('
x
', 'class')); + $this->assertSame('
x
', $remove('
x
', 'class', 'id')); + $this->assertSame("x", $remove("x", 'class')); + $this->assertSame('
', $remove('
', 'class')); + $this->assertSame('
x
', $remove('
x
', 'class')); + + [$parser] = $this->parseHtml('
x
'); + $parser->html()->first()->removeAttribute('class')->setAttribute('id', 'b'); + + $this->assertSame('
x
', $parser->html()->toHtml()); + } + + public function test_self_closing_foreign_and_component_elements_reject_closing_tags() + { + [$parser] = $this->parseHtml('
x'); + $document = $parser->html(); + [$svg, $path, $div, $dynamic] = $document->elements()->all(); + + $div->setClosingTag('
'); + $svg->setClosingTag(''); + + $this->assertSame('
x
', $document->toHtml()); + + foreach ([$path, $dynamic] as $element) { + try { + $element->setClosingTag(''); + $this->fail('Expected the closing tag to be rejected.'); + } catch (\LogicException $exception) { + $this->assertStringContainsString('cannot have a closing tag', $exception->getMessage()); + } + } + + $this->assertSame('
x
', $document->toHtml()); + } + + public function test_column_group_content_is_not_reported_as_reconstructed() + { + [$parser, $nodes] = $this->parseHtml('{{ b }}
{{ a }}

x

{{ c }}'); + $placements = $parser->html()->regionPlacements(); + + $this->assertFalse($placements[spl_object_id($nodes[1])]['reconstructed']); + $this->assertFalse($nodes[1]->htmlContext()->requiresFormattingReconstruction); + $this->assertTrue($placements[spl_object_id($nodes[2])]['reconstructed']); + $this->assertTrue($nodes[2]->htmlContext()->requiresFormattingReconstruction); + $this->assertSame('b', $nodes[2]->parentElement()->name()); + $this->assertTrue($nodes[2]->parentElement()->isSynthetic()); + } + + public function test_attribute_edits_keep_authored_whitespace_and_foreign_names() + { + [$parser] = $this->parseHtml("x"); + $document = $parser->html(); + [$anchor, $svg] = $document->elements()->all(); + + $anchor->setAttribute('href', '/new'); + + $this->assertSame(['viewBox' => '0 0 1 1'], $svg->attributes()); + $this->assertSame('0 0 1 1', $svg->attr('viewbox')); + $this->assertSame( + "x", + $document->toHtml() + ); + } + + #[DataProvider('attributeLineEndingProvider')] + public function test_attribute_values_are_read_with_normalized_line_endings($eol) + { + $template = "{{ x }}{$eol}"; + [$parser] = $this->parseHtml($template); + $document = $parser->html(); + $div = $document->first('div'); + + $this->assertSame("a\n b", $div->attr('class')); + $this->assertSame(['class' => "a\n b", 'title' => "c\nd", 'data-x' => 'e'], $div->attributes()); + $this->assertSame(['a', 'b'], $div->classes()); + $this->assertTrue($div->hasClass('b')); + $this->assertSame($template, $document->markDirty()->toHtml()); + + $div->setAttribute('title', "1\n2")->addClass('c'); + + $this->assertSame( + str_replace(["title='c{$eol}d'", "class=\"a{$eol} b\""], ["title=\"1{$eol}2\"", 'class="a b c"'], $template), + $document->toHtml() + ); + } + + public static function attributeLineEndingProvider() + { + return [ + 'lf' => ["\n"], + 'crlf' => ["\r\n"], + 'cr' => ["\r"], + ]; + } + + public function test_attribute_values_with_mixed_line_endings_are_normalized_and_rendered_verbatim() + { + $template = "
{{ x }}
\n"; + [$parser] = $this->parseHtml($template); + $document = $parser->html(); + $div = $document->first('div'); + + $this->assertSame(['class' => "a\n b\n c\n d", 'title' => "x\ny"], $div->attributes()); + $this->assertSame(['a', 'b', 'c', 'd'], $div->classes()); + + $div->setAttribute('title', "1\n2"); + + $this->assertSame("
{{ x }}
\n", $document->toHtml()); + } + + public function test_php_blocks_inside_a_tag_do_not_swallow_the_regions_after_them() + { + foreach (['', '', ''] as $block) { + $template = '
b }}>x
{{ c }}'; + [$parser, $nodes] = $this->parseHtml($template); + $document = $parser->html(); + $div = $document->first('div'); + [$condition, $after] = array_slice($nodes, -2); + + $this->assertSame('x', $div->children()[0]->content(), $template); + $this->assertSame('div', $condition->parentElement()->name(), $template); + $this->assertNull($after->parentElement(), $template); + $this->assertSame($template, $document->markDirty()->toHtml(), $template); + } + } + + public function test_closing_tags_are_read_without_dirtying_and_replaced_for_authored_elements() + { + [$parser] = $this->parseHtml('
{{ cell }}

a
b

'); + $document = $parser->html(); + $cell = $document->elements()->first(fn ($element) => $element->is('td')); + $break = $document->elements()->first(fn ($element) => $element->is('br')); + + $this->assertSame('', $cell->closingTag()); + $this->assertSame([], $break->attributes()); + $this->assertFalse($document->isDirty()); + + $cell->setClosingTag(''); + $break->setAttribute('class', 'x'); + + $this->assertSame('
{{ cell }}

a
b

', $document->toHtml()); + + foreach ([$break, $cell->parentElement()] as $element) { + try { + $element->setClosingTag(''); + $this->fail('Expected the closing tag to be rejected.'); + } catch (\LogicException $exception) { + $this->assertStringContainsString('cannot have a closing tag', $exception->getMessage()); + } + } + } + + public function test_replacing_table_content_keeps_fostered_regions_paired() + { + [$parser, $nodes] = $this->parseHtml('{{ rows }}{{ /rows }}
x
'); + $document = $parser->html(); + $opening = $nodes[0]->htmlNode(); + + $document->elements()->first(fn ($element) => $element->is('table'))->content('new'); + $opening->before(''); + + $this->assertSame('{{ rows }}{{ /rows }}
new
', $document->toHtml()); + } + + public function test_moving_a_node_out_of_the_document_marks_it_dirty() + { + [$parser] = $this->parseHtml('

{{ one }}

'); + $document = $parser->html(); + + $document->createElement('section')->append($document->elements()->first(fn ($element) => $element->is('p'))); + + $this->assertTrue($document->isDirty()); + $this->assertSame('
', $document->toHtml()); + } + + public function test_antlers_php_nodes_are_opaque_to_the_graph() + { + $template = 'attrs() $}} data-x="{{ x }}">"; ?}}{{ value }}{{? $open = "', [ + '', + 'script' => '', + 'attribute' => '', + }; + + for ($render = 0; $render < 2; $render++) { + $output = (string) $parser->parse($source, $data); + if ($context === 'attribute') { + $dom = new DOMDocument; + $dom->loadHTML($output, LIBXML_NOERROR | LIBXML_NOWARNING); + $value = $dom->getElementsByTagName('input')->item(0)->getAttribute('value'); + } else { + preg_match('~^<'.$context.'\b[^>]*>(.*)$~s', $output, $matches); + $value = $matches[1] ?? null; + } + + $this->assertSame('Hello', $value, $output); + $this->assertFalse(InstrumentationState::suppressed()); + } + } + } + + $this->assertSame($safeOutput, (string) $parser->parse($safe, $data)); + } + + public function test_tag_pair_bodies_are_instrumented_once_with_authored_offsets() + { + $this->app['statamic.tags']['echoing_pair'] = EchoingPairTag::class; + $data = ['title' => 'Hello']; + $parser = $this->instrumentedParser($data, HtmlInstrumentation::make()->attributes('data-antlers')); + + $source = '
    {{ echoing_pair }}
  • {{ title }}
  • {{ /echoing_pair }}
'; + RuntimeParser::clearRenderNodeCache(); + $output = (string) $parser->parse($source, $data); + $cacheSize = count(RuntimeParser::getNodeCache()); + + $markers = collect($this->decodedMarkers($output))->keyBy('expression'); + + $this->assertSame(['echoing_pair', 'title'], $markers->keys()->all()); + $this->assertSame(mb_strpos($source, '{{ title }}'), $markers['title']['start']); + $this->assertSame(1, substr_count($output, 'Hello')); + $this->assertMatchesRegularExpression('/
  • /', $output); + preg_match('/
  • assertSame(['kind', 'title'], array_column(HtmlInstrumentation::decode($payload[1]), 'expression')); + $this->assertSame($output, (string) $parser->parse($source, $data)); + $this->assertSame($cacheSize, count(RuntimeParser::getNodeCache())); + } + + public function test_views_rendered_inside_parsed_tag_pairs_are_instrumented_once() + { + $this->app['statamic.tags']['echoing_pair'] = EchoingPairTag::class; + $this->withFakeViews(); + $this->viewShouldReturnRaw('safety-inner', '{{ title }}'); + $data = ['title' => 'Hello']; + $parser = $this->instrumentedParser($data); + + $sources = [ + '
    {{ echoing_pair }}{{ kind }}{{ partial:safety-inner }}{{ /echoing_pair }}
    ' => 1, + '
    {{ echoing_pair:loop }}{{ kind }}{{ partial:safety-inner }}{{ /echoing_pair:loop }}
    ' => 2, + ]; + + foreach ($sources as $source => $iterations) { + $output = (string) $parser->parse($source, $data); + + $markers = collect($this->decodedMarkers($output)); + + $this->assertSame($iterations, $markers->where('expression', 'kind')->count(), $output); + $this->assertSame($iterations, $markers->where('expression', 'title')->count(), $output); + $this->assertSame(['safety-inner.antlers.html'], $markers->where('expression', 'title')->pluck('view')->unique()->all()); + $this->assertSame($iterations, substr_count($output, '')); + } + } + + public function test_tag_bodies_rendered_after_the_tag_call_keep_their_authored_offsets() + { + $this->app['statamic.tags']['echoing_pair'] = EchoingPairTag::class; + $data = ['title' => 'Hello']; + $parser = $this->instrumentedParser($data); + + $sources = [ + '
      {{ echoing_pair:rows scope="row" }}
    • {{ title }}
    • {{ /echoing_pair:rows }}
    ', + '
      {{ echoing_pair:rows as="rows" }}{{ rows }}
    • {{ title }}
    • {{ /rows }}{{ /echoing_pair:rows }}
    ', + '
      {{ echoing_pair:none }}{{ if no_results }}
    • {{ title }}
    • {{ /if }}{{ /echoing_pair:none }}
    ', + ]; + + foreach ($sources as $source) { + RuntimeParser::clearRenderNodeCache(); + + $output = (string) $parser->parse($source, $data); + $markers = collect($this->decodedMarkers($output))->where('expression', 'title')->values(); + + $this->assertCount(1, $markers, $output); + $this->assertSame(mb_strpos($source, '{{ title }}'), $markers[0]['start'], $source); + $this->assertSame(1, substr_count($output, 'Hello'), $output); + } + } + + public function test_tag_bodies_rendered_after_the_tag_call_keep_the_unsafe_context() + { + $this->app['statamic.tags']['echoing_pair'] = EchoingPairTag::class; + $this->withFakeViews(); + $this->viewShouldReturnRaw('safety-inner', '{{ title }}'); + $data = ['title' => 'Hello']; + $parser = $this->instrumentedParser($data); + + $bodies = [ + '{{ echoing_pair:trimmed_loop }} {{ partial:safety-inner }} {{ /echoing_pair:trimmed_loop }}' => 'HelloHello', + '{{ echoing_pair:rows scope="row" }}{{ partial:safety-inner }}{{ /echoing_pair:rows }}' => 'Hello', + '{{ echoing_pair:none }}{{ if no_results }}{{ partial:safety-inner }}{{ /if }}{{ /echoing_pair:none }}' => 'Hello', + ]; + + foreach ($bodies as $body => $expected) { + RuntimeParser::clearRenderNodeCache(); + + $this->assertSame(''.$expected.'', (string) $parser->parse(''.$body.'', $data)); + $this->assertSame('', (string) $parser->parse('', $data)); + $this->assertStringContainsString('HelloHelloHello<\/i><\/footer>/', $instrumented); + $this->assertSame(['slot | upper', 'footer', 'footer | upper'], $this->markerExpressions($instrumented)); + $this->assertFalse(InstrumentationState::suppressed()); + $this->assertFalse(InstrumentationState::inComponentView()); + } + + public function test_views_rendered_inside_component_slots_are_not_instrumented() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('components.review', ''); + $this->viewShouldReturnRaw('review-label', '{{ value }}'); + + $parser = $this->instrumentedParser(['value' => 'Hello'], HtmlInstrumentation::make()->attributes('data-antlers')); + + $rendered = (string) $parser->parse('{{ partial:review-label }}', ['value' => 'Hello']); + + $this->assertStringNotContainsString('', true], + 'bang closed comment' => ['', true], + 'abrupt comment' => ['', true], + 'doctype' => [' ['', true], + 'xml declaration' => [' ['', true], + 'cdata' => [' > ', false], + 'closed cdata' => ['<svg><![CDATA[ > ', true], + 'bogus closing tag' => [' ['', true], + 'script' => ['', '[S]B[E]'], + 'svg script' => ['

    [S]B[E]

    ', '

    [S]B[E]

    '], + 'uppercase tags' => ['[S]A[E]

    [S]B[E]

    ', 'A

    [S]B[E]

    '], + 'textarea in svg' => ['', ''], + 'pre' => ['
    [S]A[E]
    ', null], + 'plaintext' => ['[S]A[E]</plaintext><p>[S]B[E]</p>', '<plaintext>A</plaintext><p>B</p>'], + 'noscript' => ['<noscript>[S]A[E]</noscript><p>[S]B[E]</p>', '<noscript>A</noscript><p>[S]B[E]</p>'], + 'template' => ['<template>[S]A[E]</template>', null], + 'svg title (not rcdata)' => ['<svg><title>[S]A[E]</title></svg>', '<svg><title>A</title></svg>'], + 'iframe noembed xmp noframes' => ['<iframe>[S]A[E]</iframe><noembed>[S]B[E]</noembed><xmp>[S]C[E]</xmp><noframes>[S]D[E]</noframes><p>[S]E[E]</p>', '<iframe>A</iframe><noembed>B</noembed><xmp>C</xmp><noframes>D</noframes><p>[S]E[E]</p>'], + 'script double-escape' => ['<script><!--<script>[S]A[E]</script>[S]B[E]--></script><p>[S]C[E]</p>', '<script><!--<script>A</script>[S]B[E]--></script><p>[S]C[E]</p>'], + 'attribute layer in attr value' => ['<div title="x[L] y" class="a">[S]A[E]</div>', '<div title="x y" class="a">[S]A[E]</div>'], + 'attribute layer in single-quoted attr value' => ['<div title=\'x[L] y\'>[S]A[E]</div>', '<div title=\'x y\'>[S]A[E]</div>'], + 'attribute layer legit' => ['<div[L] class="a">[S]A[E]</div>', null], + 'marker right after <!--' => ['<!--[E] rest --><p>[S]A[E]</p>', '<!-- rest --><p>[S]A[E]</p>'], + 'marker between attrs' => ['<div class="a" [S]A[E] id="b">x</div>', '<div class="a" A id="b">x</div>'], + 'marker in tag before >' => ['<div class="a"[E]>x</div>', '<div class="a">x</div>'], + 'marker in closing tag' => ['<div>x</div[E]>', null], + 'tag name adjacent marker' => ['<div[S]>x</div>', null], + 'non-marker text <3' => ['<3 [S]A[E]', null], + 'php tag' => ['<?php echo "<!-- x"; ?>[S]A[E]', null], + 'bogus comment <!x' => ['<!x [S]A[E]><p>[S]B[E]</p>', '<!x A><p>[S]B[E]</p>'], + 'bogus comment <?x' => ['<?x [S]A[E]><p>[S]B[E]</p>', '<?x A><p>[S]B[E]</p>'], + 'doctype' => ['<!DOCTYPE html [S]A[E]><p>[S]B[E]</p>', '<!DOCTYPE html A><p>[S]B[E]</p>'], + 'xml declaration' => ['<?xml version="1.0" [S]A[E]?><p>[S]B[E]</p>', '<?xml version="1.0" A?><p>[S]B[E]</p>'], + 'cdata' => ['<svg><![CDATA[ > [S]A[E] ]]><text>[S]B[E]</text></svg>', '<svg><![CDATA[ > A ]]><text>[S]B[E]</text></svg>'], + 'bogus closing tag' => ['</ x [S]A[E]><p>[S]B[E]</p>', '</ x A><p>[S]B[E]</p>'], + 'empty closing tag' => ['</>[S]A[E]', null], + 'closing slash at end' => ['<p>[S]A[E]</p></', null], + 'char ref' => ['<p>&am[S]p;[E]</p>', null], + 'marker inside attr then text' => ['<a href="/x[S]">[S]A[E]</a>', '<a href="/x">[S]A[E]</a>'], + 'unterminated tag' => ['<div class="a[S]A[E]', '<div class="aA'], + 'end marker without id in attr' => ['<div title="<!-- antlers:end -->">x</div>', '<div title="">x</div>'], + 'textarea case with attr' => ['<TextArea rows=3>[S]A[E]</TEXTAREA><p>[S]B[E]</p>', '<TextArea rows=3>A</TEXTAREA><p>[S]B[E]</p>'], + 'title inside svg then html title' => ['<svg><title>[S]A[E]</title></svg><title>[S]B[E]</title>', '<svg><title>A</title></svg><title>B</title>'], + 'script with </script inside string then real end' => ['<script>var a = "</scriptx>"; [S]A[E]</script><p>[S]B[E]</p>', null], + ]; + } + + #[DataProvider('hostileMarkupAcrossConfigurationsProvider')] + public function test_misplaced_marker_removal_is_pinned_across_configurations($configuration, $input, $expected) + { + $instrumenters = match ($configuration) { + 'comments+attrs' => [HtmlInstrumentation::make()->comments('antlers')->attributes('data-antlers')], + 'comments' => [HtmlInstrumentation::make()->comments('antlers')], + 'attrs' => [HtmlInstrumentation::make()->withoutComments()->attributes('data-antlers')], + 'two instrumenters' => [ + HtmlInstrumentation::make()->comments('trace')->attributes('data-trace'), + HtmlInstrumentation::make()->comments('antlers')->attributes('data-antlers')->attribute('data-antlers-contexts', fn () => 'x'), + ], + 'factory+attrs' => [HtmlInstrumentation::make()->markersUsing(fn () => ['<!--s-->', '<!--e-->'])->attributes('data-antlers')], + }; + + $input = $this->decodeMarkerPlaceholders($input); + $expected = $expected === null ? $input : $this->decodeMarkerPlaceholders($expected); + $cleaned = HtmlInstrumentation::removeMisplacedMarkers($input, $instrumenters); + + $this->assertSame($expected, $cleaned); + $this->assertSame($cleaned, HtmlInstrumentation::removeMisplacedMarkers($cleaned, $instrumenters)); + } + + public static function hostileMarkupAcrossConfigurationsProvider() + { + return [ + 'both prefixes and layers are stripped inside markup' => ['two instrumenters', '<div[TL][C] title="[TE] x[L]">[TS]A[TE][S]B[E]<script>[S]c[E][TE]</script><!-- note [TS][C] --></div>', '<div[TL][C] title=" x">[TS]A[TE][S]B[E]<script>c</script><!-- note --></div>'], + 'a layer whose name extends another is matched on its own' => ['two instrumenters', '<div title="x[C] y" data-x=\'z[C]\'>[S]A[E]</div>', '<div title="x y" data-x=\'z\'>[S]A[E]</div>'], + 'custom comment factories leave comment markers alone' => ['factory+attrs', '<div title="[S] x[L]">[S]A[E]</div><!-- [S] -->', '<div title="[S] x">[S]A[E]</div><!-- [S] -->'], + 'attribute layers only' => ['attrs', '<p title="a[L]"[L]>[S]x[E]</p><input value=\'q[L]\'><script>[L]</script>', '<p title="a"[L]>[S]x[E]</p><input value=\'q\'><script></script>'], + 'comment markers only leave attribute layers alone' => ['comments', '<p title="a[L] [E]"[L]>[S]x[E]</p>', '<p title="a[L] "[L]>[S]x[E]</p>'], + 'a high byte after a less-than sign is text' => ['comments+attrs', '<é title="[E]">[S]x[E]</é>', null], + 'unterminated comments swallow the rest of the document' => ['comments+attrs', '<!-- open [S]A[E] <p>[S]B[E]', '<!-- open A <p>B'], + 'raw text elements clean their tag and their content' => ['comments+attrs', '<script title="[E]">var a = 1; [S]x[E]</script>[S]y[E]<textarea[L]>[S]z[E]</TEXTAREA>', '<script title="">var a = 1; x</script>[S]y[E]<textarea[L]>z</TEXTAREA>'], + 'closing tags are scanned for misplaced markers too' => ['comments+attrs', '<div>x</div title="[E]" [E]>[S]y[E]', '<div>x</div title="" >[S]y[E]'], + 'crlf line endings inside tags' => ['comments+attrs', '<div[CRLF]class="a"[CRLF][E]>[CRLF][S]x[E][CRLF]</div>', '<div[CRLF]class="a"[CRLF]>[CRLF][S]x[E][CRLF]</div>'], + 'single quotes may contain double quotes' => ['comments+attrs', '<a title=\'say "hi" [L] [E]\'>[S]x[E]</a>', '<a title=\'say "hi" \'>[S]x[E]</a>'], + 'markers inside php blocks are treated as text' => ['comments+attrs', '<?php echo "<a title=\\"[E]\\">"; ?>[S]A[E]', '<?php echo "<a title=\\"\\">"; ?>[S]A[E]'], + 'lone less-than signs are text' => ['comments+attrs', '< [E] <3[E] <? [E]', '< [E] <3[E] <? '], + 'bogus comments swallow markers up to their end' => ['comments+attrs', '</ [E] <3[E] <![E] <!-[E]', '</ <3 <! <!-'], + 'unquoted values end at whitespace' => ['comments+attrs', '<div a=b[E]c d="e"[E]>[S]x[E]</div>', '<div a=bc d="e">[S]x[E]</div>'], + 'empty and odd comments' => ['comments+attrs', '<!-->[E]<!--->[E]<!---->[E]<!-- -- [E] -->[E]<!--[E]--!>[E]-->[E]', '<!-->[E]<!--->[E]<!---->[E]<!-- -- -->[E]<!----!>[E]-->[E]'], + 'a raw text end tag inside a string ends the element' => ['comments+attrs', '<script>var a = "</script>"; [S]x[E]</script>[S]y[E]', null], + 'markers adjacent to the tag name are kept' => ['comments+attrs', '<div[E] class="a"[E]>[S]x[E]</div[E]>', null], + 'a double quoted value ending in a layer name swallows the closing quote' => ['comments+attrs', '<div title="x[L] class="a">[S]x[E]</div>', '<div title="x class="a">[S]x[E]</div>'], + ]; + } + + private function decodeMarkerPlaceholders(string $value): string + { + return str_replace( + ['[S]', '[E]', '[L]', '[TS]', '[TE]', '[TL]', '[C]', '[T]', '[CRLF]'], + [ + '<!-- antlers:start '.base64_encode('{"id":1}').' -->', + '<!-- antlers:end 1 -->', + ' data-antlers="W10="', + '<!-- trace:start YQ== -->', + '<!-- trace:end 2 -->', + ' data-trace="W10="', + ' data-antlers-contexts="eA=="', + "\t", + "\r\n", + ], + $value + ); + } + + public function test_already_instrumented_templates_are_left_alone() + { + $instrumenter = HtmlInstrumentation::make(); + $instrumented = $instrumenter->instrument('<p>{{ title }}</p>'); + + $this->assertSame('{{ wrapper }}'.$instrumented.'{{ /wrapper }}', $instrumenter->instrument('{{ wrapper }}'.$instrumented.'{{ /wrapper }}')); + } + + public function test_skipped_regions_report_why_they_could_not_be_instrumented() + { + $this->assertSame( + ['if show='.HtmlInstrumentation::SKIP_CROSS_CONTAINER_PAIR], + $this->skipReasons('<section>{{ if show }}<div>{{ /if }}</section>') + ); + + $this->assertSame( + ['if a='.HtmlInstrumentation::SKIP_NO_STRATEGY], + $this->skipReasons('<script>{{ if a }}x{{ /if }}</script>') + ); + + $this->assertSame([ + 'commented='.HtmlInstrumentation::SKIP_INSIDE_HTML_COMMENT, + 'typed='.HtmlInstrumentation::SKIP_INSIDE_DOCTYPE, + 'kind='.HtmlInstrumentation::SKIP_DYNAMIC_MARKUP, + 'inside='.HtmlInstrumentation::SKIP_DYNAMIC_MARKUP, + 'kind='.HtmlInstrumentation::SKIP_DYNAMIC_MARKUP, + ], $this->skipReasons('<!-- {{ commented }} --><!doctype {{ typed }}><x-{{ kind }}>{{ inside }}</x-{{ kind }}>')); + + $this->assertSame([], $this->skipReasons('<p>{{ title }}</p>')); + } + + public function test_regions_inside_recovered_end_tags_are_left_alone() + { + $reasons = []; + $template = '<p>a</br {{ x }}>b<b>c</b {{ y }}></p>'; + + $instrumented = HtmlInstrumentation::make() + ->attributes('data-antlers') + ->onSkip(function ($metadata, $region, $reason) use (&$reasons) { + $reasons[] = $metadata['expression'].'='.$reason; + }) + ->instrument($template); + + $this->assertSame($template, $instrumented); + $this->assertSame( + ['x='.HtmlInstrumentation::SKIP_CLOSING_TAG_MARKUP, 'y='.HtmlInstrumentation::SKIP_CLOSING_TAG_MARKUP], + $reasons + ); + } + + public function test_skip_reporting_instrumenters_bypass_the_result_cache() + { + $reasons = []; + $collect = function ($metadata, $region, $reason) use (&$reasons) { + $reasons[] = $metadata['expression'].'='.$reason; + }; + $template = '<title>{{ blocked }}</title><p>{{ ok }}</p>'; + $expected = 'blocked='.HtmlInstrumentation::SKIP_NO_STRATEGY; + $results = new ReflectionProperty(HtmlInstrumentation::class, 'results'); + $results->setValue(null, []); + $memoize = fn () => $results->setValue(null, array_map(fn ($entry) => ['memoized', $entry[1], $entry[2]], $results->getValue())); + + $instrumenter = HtmlInstrumentation::make()->onSkip($collect); + $first = $instrumenter->instrument($template); + + $this->assertCount(1, $results->getValue()); + + $memoize(); + + $this->assertSame($first, $instrumenter->instrument($template)); + $this->assertSame([$expected, $expected], $reasons); + + $reasons = []; + $results->setValue(null, []); + $instrumenters = [HtmlInstrumentation::make()->comments('other'), HtmlInstrumentation::make()->onSkip($collect)]; + $first = HtmlInstrumentation::instrumentTogether($template, $instrumenters); + + $this->assertCount(1, $results->getValue()); + + $memoize(); + + $this->assertSame($first, HtmlInstrumentation::instrumentTogether($template, $instrumenters)); + $this->assertSame([$expected, $expected], $reasons); + } + + private function skipReasons($template) + { + $reasons = []; + + $instrumented = HtmlInstrumentation::make() + ->onSkip(function ($metadata, $region, $reason) use (&$reasons) { + $reasons[] = $metadata['expression'].'='.$reason; + }) + ->instrument($template); + + if ($reasons === []) { + $this->assertStringContainsString('<!-- antlers:start ', $instrumented); + } + + return $reasons; + } + + private function plainCommentInstrumenter() + { + return HtmlInstrumentation::make()->markersUsing(function ($metadata) { + $label = $metadata['type'].':'.$metadata['expression']; + + return ['<!-- start:'.$label.' -->', '<!-- end:'.$label.' -->']; + }); + } +} diff --git a/tests/View/Instrumentation/InstrumentationConfigurationTest.php b/tests/View/Instrumentation/InstrumentationConfigurationTest.php new file mode 100644 index 00000000000..bcfc95ba3cf --- /dev/null +++ b/tests/View/Instrumentation/InstrumentationConfigurationTest.php @@ -0,0 +1,185 @@ +<?php + +namespace Tests\View\Instrumentation; + +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\Facades\Antlers; +use Statamic\Tags\Tags; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Debugbar\AntlersProfiler\PerformanceTracer; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Instrumentation\InstrumentationManager; +use Tests\Antlers\ParserTestCase; +use Tests\FakesViews; +use Tests\View\Instrumentation\Concerns\InteractsWithRuntimeConfigurations; +use Tests\View\Instrumentation\Support\RecordingLegacyTracer; + +class InstrumentationConfigurationTest extends ParserTestCase +{ + use FakesViews, InteractsWithRuntimeConfigurations; + + protected function setUp(): void + { + parent::setUp(); + + GlobalRuntimeState::$isEvaluatingUserData = false; + } + + public function test_marker_configuration_covers_normal_renders_and_nested_views() + { + config(['statamic.antlers.instrumentation' => [ + 'enabled' => true, + 'comments' => true, + 'prefix' => 'trace', + 'attributes' => ['data-trace'], + 'metadata' => ['app' => 'demo'], + 'componentPrefixes' => ['ui-'], + ]]); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('marker-prefixed', '<ui-box>{{ title }}</ui-box>'); + $this->assertSame('<ui-box>Hello</ui-box>', view('marker-prefixed', ['title' => 'Hello'])->render()); + + $this->viewShouldReturnRaw('marker-child', '<strong>{{ title }}</strong>'); + $this->viewShouldReturnRaw('marker-page', '<main><a href="{{ url }}">{{ title }}</a>{{ partial:marker-child }}</main>'); + + $data = ['title' => 'Hello', 'url' => '/example']; + $rendered = view('marker-page', $data)->render(); + $this->assertStringContainsString('<!-- trace:start ', $rendered); + $this->assertStringContainsString('href="/example"', $rendered); + $this->assertMatchesRegularExpression('/<strong data-trace="[^"]+"><!-- trace:start .*? -->Hello<!-- trace:end \d+ --><\/strong>/', $rendered); + $this->assertSame('demo', HtmlInstrumentation::decode($rendered)['app']); + $this->assertSame($rendered, view('marker-page', $data)->render()); + } + + public function test_multiple_instrumenters_keep_authored_antlers_coordinates() + { + $observed = []; + Runtime::register(HtmlInstrumentation::make()->markersUsing(function ($metadata) use (&$observed) { + $observed[] = $metadata; + + return ["<!-- custom\nstart -->", '<!-- custom end -->']; + })->attributes('data-first')); + Runtime::register(HtmlInstrumentation::make()->comments('second')->attributes('data-second')); + $source = "<main>é\r\n<p>{{ title }}</p></main>"; + + for ($render = 0; $render < 2; $render++) { + $output = (string) app(ParserContract::class)->parse($source, ['title' => 'Hello']); + preg_match_all('/<!-- second:start (.*?) -->/', $output, $matches); + $metadata = array_map([HtmlInstrumentation::class, 'decode'], $matches[1]); + $this->assertCount(1, $metadata); + + $entry = $metadata[0]; + $this->assertSame(mb_strpos($source, '{{ title }}'), $entry['start']); + $this->assertSame(mb_strpos($source, '{{ title }}') + 11, $entry['end']); + $this->assertSame(2, $entry['line']); + $first = collect($observed)->firstWhere('expression', 'title'); + $this->assertSame($first['start'], $entry['start']); + $this->assertSame($first['end'], $entry['end']); + $this->assertStringContainsString('data-first=', $output); + $this->assertStringContainsString('data-second=', $output); + } + } + + public function test_marker_positions_account_for_view_front_matter() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'comments' => true, 'prefix' => 'trace']]); + $this->app['statamic.tags']['string_template'] = StringTemplateTag::class; + + $directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'statamic-instrumentation-'.uniqid(); + mkdir($directory); + $path = $directory.DIRECTORY_SEPARATOR.'front-matter-page.antlers.html'; + $contents = "---\ntitle: Héllo\n---\n\n<p>{{ title }}</p><div>{{ string_template }}</div><i>{{ snippet | antlers }}</i>"; + file_put_contents($path, $contents); + $this->app['view']->addLocation($directory); + + try { + $rendered = view('front-matter-page', ['title' => 'Hello', 'snippet' => '<b>{{ title }}</b>'])->render(); + } finally { + unlink($path); + rmdir($directory); + } + + preg_match_all('/<!-- trace:start (.*?) -->/', $rendered, $matches); + [$metadata, $tag, $snippet, $modifier] = array_map([HtmlInstrumentation::class, 'decode'], $matches[1]); + + $this->assertStringContainsString('Hello<!-- trace:end 1 --></p>', $rendered); + $this->assertSame(5, $metadata['line']); + $this->assertSame(mb_strpos($contents, '{{ title }}'), $metadata['start']); + $this->assertSame(mb_strpos($contents, '{{ title }}') + 11, $metadata['end']); + $this->assertSame('front-matter-page.antlers.html', $metadata['view']); + $this->assertSame('front-matter-page.antlers.html', $tag['view']); + $this->assertSame(['title', 1, 3], [$snippet['expression'], $snippet['line'], $snippet['start']]); + $this->assertArrayNotHasKey('view', $snippet); + $this->assertSame('snippet | antlers', $modifier['expression']); + $this->assertMatchesRegularExpression('/<i><!-- trace:start [^ ]+ --><b>Hello<\/b><!-- trace:end \d+ --><\/i>/', $rendered); + } + + public function test_configuration_changes_apply_to_subsequent_parsers() + { + $this->app->forgetInstance(InstrumentationManager::class); + + config(['statamic.antlers.instrumentation' => [ + 'enabled' => true, + 'comments' => true, + 'prefix' => 'first', + ]]); + + $this->app->make(ParserContract::class); + + config(['statamic.antlers.instrumentation' => [ + 'enabled' => true, + 'comments' => true, + 'prefix' => 'second', + ]]); + + $rendered = (string) $this->app->make(ParserContract::class) + ->parse('<p>{{ title }}</p>', ['title' => 'Hello']); + + $this->assertCount(1, $this->runtimeConfiguration()->getPreparsers()); + $this->assertSame(1, substr_count($rendered, '<!-- second:start')); + $this->assertStringNotContainsString('first:start', $rendered); + + config(['statamic.antlers.instrumentation.enabled' => false]); + + $rendered = (string) $this->app->make(ParserContract::class) + ->parse('<p>{{ title }}</p>', ['title' => 'Hello']); + + $this->assertSame('<p>Hello</p>', $rendered); + } + + public function test_withdrawing_the_profiler_tracer_leaves_user_tracers_registered() + { + $userTracer = new RecordingLegacyTracer; + Runtime::register($userTracer, ['antlers']); + + $this->app->instance('debugbar', new InstrumentationConfigurationTestDebugbar); + $this->app->make(ParserContract::class); + + config(['statamic.antlers.debugbar' => false]); + $this->app->make(ParserContract::class); + + $config = $this->runtimeConfiguration(); + + $this->assertNotContains($this->app->make(PerformanceTracer::class), $config->traceManager->tracers()); + $this->assertContains($userTracer, $config->traceManager->tracers()); + $this->assertTrue($config->isTracingEnabled); + } +} + +class InstrumentationConfigurationTestDebugbar +{ + public function isEnabled() + { + return true; + } +} + +class StringTemplateTag extends Tags +{ + public function index() + { + return Antlers::parse('<b>{{ title }}</b>', $this->context->all(), true); + } +} diff --git a/tests/View/Instrumentation/InstrumentationLifecycleTest.php b/tests/View/Instrumentation/InstrumentationLifecycleTest.php new file mode 100644 index 00000000000..45409106d40 --- /dev/null +++ b/tests/View/Instrumentation/InstrumentationLifecycleTest.php @@ -0,0 +1,149 @@ +<?php + +namespace Tests\View\Instrumentation; + +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\Tags\Loader; +use Statamic\Tags\Tags; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Tests\Antlers\ParserTestCase; +use Tests\FakesViews; + +class InstrumentationLifecycleTest extends ParserTestCase +{ + use FakesViews; + + const PLAIN = '<div><p>[12][eq]</p></div>'; + + protected function setUp(): void + { + parent::setUp(); + + GlobalRuntimeState::$isEvaluatingUserData = false; + + $this->app['statamic.tags']['lifecycle_spy'] = LifecycleSpyTag::class; + $this->app['statamic.tags']['delegate'] = DelegatingIncludeTag::class; + LifecycleSpyTag::$count = 0; + + $this->withFakeViews(); + $this->viewShouldReturnRaw('card', '<p>[{{ slot | length }}][{{ if slot == "<b>Hello</b>" }}eq{{ else }}ne{{ /if }}]</p>'); + $this->viewShouldReturnRaw('page', '<div>{{ partial:card }}<b>{{ title }}</b>{{ /partial:card }}</div>'); + } + + private function render(): string + { + return view('page', ['title' => 'Hello'])->render(); + } + + private function enable(string $prefix = 'antlers'): void + { + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'comments' => true, 'prefix' => $prefix]]); + } + + private function disable(): void + { + config(['statamic.antlers.instrumentation.enabled' => false]); + } + + public function test_toggling_instrumentation_takes_effect_on_the_next_render() + { + $this->disable(); + $this->assertSame(self::PLAIN, $this->render()); + + $this->enable(); + $rendered = $this->render(); + + $this->assertStringContainsString('<!-- antlers:start ', $rendered); + $this->assertSame(self::PLAIN, Runtime::removeMarkers($rendered)); + + $this->disable(); + + $this->assertSame(self::PLAIN, $this->render()); + $this->assertFalse(Runtime::instrumenting()); + } + + public function test_changing_the_prefix_after_rendering_emits_only_the_new_prefix() + { + $this->enable('first'); + $this->assertStringContainsString('<!-- first:start ', $this->render()); + + $this->enable('second'); + $rendered = $this->render(); + + $this->assertStringContainsString('<!-- second:start ', $rendered); + $this->assertStringNotContainsString('first:', $rendered); + $this->assertSame(self::PLAIN, Runtime::removeMarkers($rendered)); + } + + public function test_parsers_resolved_before_a_configuration_change_follow_it() + { + $this->enable(); + $parser = app(ParserContract::class); + $this->assertStringContainsString('<!-- antlers:start ', (string) $parser->parse('<p>{{ title }}</p>', ['title' => 'Hello'])); + + $this->disable(); + $this->assertSame('<p>Hello</p>', (string) $parser->parse('<p>{{ title }}</p>', ['title' => 'Hello'])); + + $this->enable('again'); + $this->assertStringContainsString('<!-- again:start ', (string) $parser->parse('<p>{{ title }}</p>', ['title' => 'Hello'])); + } + + public function test_the_cache_tag_caches_again_once_instrumentation_is_disabled() + { + $this->viewShouldReturnRaw('cached', '<div>{{ cache }}<p>{{ lifecycle_spy }}</p>{{ /cache }}</div>'); + + $this->enable(); + view('cached')->render(); + view('cached')->render(); + $this->assertSame(2, LifecycleSpyTag::$count); + + $this->disable(); + $this->assertSame('<div><p>spy</p></div>', view('cached')->render()); + $this->assertSame('<div><p>spy</p></div>', view('cached')->render()); + $this->assertSame(3, LifecycleSpyTag::$count); + } + + public function test_includes_rendered_by_other_tags_pass_instrumented_slots_to_their_views() + { + $this->viewShouldReturnRaw('wrapper', '<section>{{ slot }}</section>'); + $this->viewShouldReturnRaw('delegated', '<div>{{ delegate }}<b>{{ title }}</b>{{ /delegate }}</div>'); + + $this->disable(); + $plain = view('delegated', ['title' => 'Hello'])->render(); + $this->assertSame('<div><section><b>Hello</b></section></div>', $plain); + + $this->enable(); + $rendered = view('delegated', ['title' => 'Hello'])->render(); + + $this->assertMatchesRegularExpression('/<section><!-- antlers:start [^ ]+ --><b><!-- antlers:start [^ ]+ -->Hello<!-- antlers:end \d+ --><\/b><!-- antlers:end \d+ --><\/section>/', $rendered); + $this->assertSame($plain, Runtime::removeMarkers($rendered)); + } +} + +class LifecycleSpyTag extends Tags +{ + public static $count = 0; + + public function index() + { + static::$count++; + + return 'spy'; + } +} + +class DelegatingIncludeTag extends Tags +{ + public function index() + { + return app(Loader::class)->load('include', [ + 'parser' => $this->parser, + 'params' => ['src' => 'wrapper'], + 'content' => $this->content, + 'context' => $this->context->all(), + 'tag' => 'include:wrapper', + 'tag_method' => 'wrapper', + ])->wildcard('wrapper'); + } +} diff --git a/tests/View/Instrumentation/InstrumentationManagerTest.php b/tests/View/Instrumentation/InstrumentationManagerTest.php new file mode 100644 index 00000000000..37aa958e834 --- /dev/null +++ b/tests/View/Instrumentation/InstrumentationManagerTest.php @@ -0,0 +1,131 @@ +<?php + +namespace Tests\View\Instrumentation; + +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use Statamic\Contracts\View\Antlers\Parser; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Instrumentation\InstrumentationManager; +use stdClass; +use Tests\TestCase; +use Tests\View\Instrumentation\Concerns\InteractsWithRuntimeConfigurations; +use Tests\View\Instrumentation\Support\RecordingLegacyTracer; +use Tests\View\Instrumentation\Support\RecordingSpanTracer; + +class InstrumentationManagerTest extends TestCase +{ + use InteractsWithRuntimeConfigurations; + + protected function setUp(): void + { + parent::setUp(); + + GlobalRuntimeState::$isEvaluatingUserData = false; + } + + public function test_tracers_can_be_registered_and_forgotten_by_class_name() + { + $parser = app(Parser::class); + $configuration = $this->runtimeConfiguration($parser); + + $this->assertFalse($configuration->isTracingEnabled); + + Runtime::register(RecordingLegacyTracer::class)->register(RecordingLegacyTracer::class); + + $tracers = $configuration->traceManager->tracers(); + + $this->assertCount(1, $tracers); + $this->assertInstanceOf(RecordingLegacyTracer::class, $tracers[0]); + $this->assertTrue($configuration->isTracingEnabled); + + $parser->parse('{{ title }}', ['title' => 'Hello']); + + $this->assertNotEmpty($tracers[0]->entered); + + Runtime::forget(RecordingLegacyTracer::class); + + $this->assertSame([], $configuration->traceManager->tracers()); + $this->assertFalse($configuration->isTracingEnabled); + + Runtime::register(RecordingLegacyTracer::class); + + $this->assertCount(1, $configuration->traceManager->tracers()); + $this->assertNotSame($tracers[0], $configuration->traceManager->tracers()[0]); + $this->assertTrue($configuration->isTracingEnabled); + } + + public function test_pieces_that_are_not_instrumentation_are_rejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Instrumentation pieces must be an HtmlInstrumentation instance, a TracerContract, or a RuntimeTracerContract.'); + + Runtime::register(new stdClass); + } + + #[DataProvider('invalidEngines')] + public function test_invalid_engine_lists_are_rejected(array $engines, string $message) + { + $configuration = $this->runtimeConfiguration(); + $tracer = new RecordingLegacyTracer; + + try { + Runtime::register($tracer, $engines); + $this->fail('Expected the engine list to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + + $this->assertFalse($configuration->isTracingEnabled); + + Runtime::register($tracer); + + try { + Runtime::forget($tracer, $engines); + $this->fail('Expected the engine list to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + + $this->assertSame([$tracer], $configuration->traceManager->tracers()); + $this->assertTrue($configuration->isTracingEnabled); + } + + public static function invalidEngines(): array + { + return [ + 'empty' => [[], 'At least one engine must be provided.'], + 'unknown' => [['blade'], 'Unknown instrumentation engine [blade].'], + 'non-string' => [[1], 'Instrumentation engine names must be strings.'], + ]; + } + + public function test_flush_clears_every_registered_piece_and_resolved_instance() + { + $manager = app(InstrumentationManager::class); + $parser = app(Parser::class); + $configuration = $this->runtimeConfiguration($parser); + $instrumentation = HtmlInstrumentation::make(); + + $manager->register($instrumentation)->register(RecordingLegacyTracer::class)->register(new RecordingSpanTracer); + $resolved = $configuration->traceManager->tracers()[0]; + + $this->assertTrue($manager->instrumenting()); + $this->assertStringContainsString('<!-- antlers:start ', (string) $parser->parse('<p>{{ title }}</p>', ['title' => 'Hello'])); + + $this->assertSame($manager, $manager->flush()); + + $this->assertFalse($manager->instrumenting()); + $this->assertSame('<p>Hello</p>', (string) $parser->parse('<p>{{ title }}</p>', ['title' => 'Hello'])); + $this->assertSame([], $configuration->traceManager->tracers()); + $this->assertSame([], $configuration->traceManager->spanTracers()); + $this->assertFalse($configuration->isTracingEnabled); + + $manager->register(RecordingLegacyTracer::class); + + $this->assertNotSame($resolved, $configuration->traceManager->tracers()[0]); + $this->assertTrue($configuration->isTracingEnabled); + } +} diff --git a/tests/View/Instrumentation/InstrumentationNeutralityTest.php b/tests/View/Instrumentation/InstrumentationNeutralityTest.php new file mode 100644 index 00000000000..a8250528d47 --- /dev/null +++ b/tests/View/Instrumentation/InstrumentationNeutralityTest.php @@ -0,0 +1,179 @@ +<?php + +namespace Tests\View\Instrumentation; + +use PHPUnit\Framework\Attributes\DataProvider; +use Statamic\Facades\Antlers; +use Statamic\Fields\Field; +use Statamic\Fields\Value; +use Statamic\Fieldtypes\Text; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Tests\Antlers\ParserTestCase; +use Tests\FakesViews; +use Tests\View\Instrumentation\Concerns\DecodesMarkers; + +class InstrumentationNeutralityTest extends ParserTestCase +{ + use DecodesMarkers, FakesViews; + + #[DataProvider('templates')] + public function test_markers_never_change_what_a_template_renders(array $views, string $expected) + { + [$plain, $instrumented] = $this->renderWithAndWithoutInstrumentation($views); + + $this->assertSame($expected, $plain); + $this->assertStringContainsString('<!-- antlers:start ', $instrumented); + $this->assertSame($plain, Runtime::removeMarkers($instrumented)); + } + + public static function templates(): array + { + $card = '<div class="card">{{ if slot }}<div class="body">{{ slot }}</div>{{ else }}<div class="empty">empty</div>{{ /if }}</div>'; + $inner = '<em>{{ title }}</em>'; + + return [ + 'an empty slot stays empty' => [ + ['card' => $card, 'page' => '<section>{{ partial:card }}{{ if show }}<b>{{ title }}</b>{{ /if }}{{ /partial:card }}</section>'], + '<section><div class="card"><div class="empty">empty</div></div></section>', + ], + 'an empty named slot stays empty' => [ + [ + 'card' => '<div>{{ if slot:footer }}<footer>{{ slot:footer }}</footer>{{ else }}<em>none</em>{{ /if }}</div>', + 'page' => '<section>{{ partial:card }}{{ slot:footer }}{{ if show }}<i>{{ title }}</i>{{ /if }}{{ /slot:footer }}{{ /partial:card }}</section>', + ], + '<section><div><em>none</em></div></section>', + ], + 'slots can be modified' => [ + [ + 'card' => '<div data-length="{{ slot | length }}">{{ slot | truncate(8) }}</div>', + 'page' => '<section>{{ partial:card }}<b>{{ title }}</b>{{ /partial:card }}</section>', + ], + '<section><div data-length="12"><b>Hello</div></section>', + ], + 'named slots can be modified' => [ + [ + 'card' => '<div>{{ slot:footer | upper }}</div>', + 'page' => '<section>{{ partial:card }}{{ slot:footer }}<i>{{ title }}</i>{{ /slot:footer }}{{ /partial:card }}</section>', + ], + '<section><div><I>HELLO</I></div></section>', + ], + 'slot whitespace is trimmed through markers' => [ + ['card' => '<div>[{{ slot }}]</div>', 'page' => "<section>{{ partial:card }}\n {{ if title }}\n <b>{{ title }}</b>\n {{ /if }}\n{{ /partial:card }}</section>"], + '<section><div>[<b>Hello</b>]</div></section>', + ], + 'empty include slots render the same' => [ + ['card' => $card, 'page' => '<section>{{ include:card }}{{ if show }}<b>{{ title }}</b>{{ /if }}{{ /include:card }}</section>'], + '<section><div class="card"><div class="body"></div></div></section>', + ], + 'include slots can be modified' => [ + ['card' => '<div>{{ slot | upper }}</div>', 'page' => '<section>{{ include:card }}<b>{{ title }}</b>{{ /include:card }}</section>'], + '<section><div><B>HELLO</B></div></section>', + ], + 'interpolated views can be modified' => [ + ['inner' => $inner, 'page' => '<div>{{ {partial:inner} | truncate(8) }}</div>'], + '<div><em>Hell</div>', + ], + 'assigned views can be compared' => [ + ['inner' => $inner, 'page' => '{{ view = {partial:inner} }}<p>{{ if view == "<em>Hello</em>" }}match{{ else }}different{{ /if }}</p>'], + '<p>match</p>', + ], + 'interpolated views can be parameters' => [ + ['inner' => $inner, 'echo' => '<p title="{{ label | strip_tags }}">{{ label | length }}</p>', 'page' => '<div>{{ partial:echo label="{partial:inner}" }}</div>'], + '<div><p title="Hello">14</p></div>', + ], + 'antlers fields can be modified' => [ + ['page' => '<div>{{ intro | truncate(14) }}</div><p>{{ if intro == "Read <b>Hello</b> now" }}match{{ else }}different{{ /if }}</p>'], + '<div>Read <b>Hello<</div><p>match</p>', + ], + 'the antlers modifier can be chained' => [ + ['page' => '<div>{{ snippet | antlers | length }}</div><p>{{ snippet | antlers | upper }}</p>'], + '<div>12</div><p><B>HELLO</B></p>', + ], + 'line endings follow the authored template' => [ + ['page' => "<pre>{{ body }}</pre>\n<p>{{ title }}</p><p>{{ subtitle }}</p>"], + "<pre>one\ntwo</pre>\n<p>Hello</p><p>World</p>", + ], + ]; + } + + public function test_slots_printed_as_element_content_keep_their_markers() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('card', '<div title="{{ slot }}">{{ if slot }}<p>{{ slot }}</p><aside>{{ slot:footer }}</aside>{{ /if }}</div>'); + $this->viewShouldReturnRaw('page', "<section>{{ partial:card }}\n{{ if title }} <b>{{ title }}</b> {{ /if }}\n{{ slot:footer }}<i>{{ title }}</i>{{ /slot:footer }}{{ /partial:card }}</section>"); + Runtime::register(HtmlInstrumentation::make()); + + $rendered = view('page', ['title' => 'Hello'])->render(); + + preg_match('~<p>(.*)</p><aside>(.*)</aside>~s', $rendered, $printed); + + $this->assertStringContainsString('<div title="<b>Hello</b>">', $rendered); + $this->assertSame(['slot', 'if title', 'title', 'slot:footer'], $this->markerExpressions($printed[1])); + $this->assertSame(['slot:footer', 'title'], $this->markerExpressions($printed[2])); + $this->assertSame( + '<section><div title="<b>Hello</b>"><p><b>Hello</b></p><aside><i>Hello</i></aside></div></section>', + Runtime::removeMarkers($rendered) + ); + } + + public function test_user_content_is_never_instrumented() + { + config(['statamic.antlers.allowedContentTags' => ['partial:*']]); + $this->withFakeViews(); + $this->viewShouldReturnRaw('inner', '<em>{{ title }}</em>'); + $this->viewShouldReturnRaw('page', '<div>{{ intro }}</div><p>{{ partial:inner }}</p>'); + Runtime::register(HtmlInstrumentation::make()); + + $intro = new Value('Read <b>{{ title }}</b> {{ partial:inner }}', 'intro', (new Text)->setField(new Field('intro', ['type' => 'text', 'antlers' => true]))); + $rendered = view('page', ['title' => 'Hello', 'intro' => $intro])->render(); + + $this->assertSame(['intro', 'partial:inner', 'title'], $this->markerExpressions($rendered)); + $this->assertStringContainsString('-->Read <b>Hello</b> <em>Hello</em><!-- antlers:end 1 -->', $rendered); + $this->assertSame( + '<div>Read <b>Hello</b> <em>Hello</em></div><p><em>Hello</em></p>', + Runtime::removeMarkers($rendered) + ); + + $template = '<p>{{ title }}</p>'; + + $this->assertSame('<p>Hello</p>', (string) Antlers::parse($template, ['title' => 'Hello'])); + $this->assertStringContainsString('<p><!-- antlers:start ', (string) Antlers::parse($template, ['title' => 'Hello'], true)); + } + + public function test_parsing_without_instrumentation_suppresses_nested_renders_too() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('inner', '<em>{{ title }}</em>'); + Runtime::register(HtmlInstrumentation::make()); + $template = '<p>{{ partial:inner }}</p>'; + $data = ['title' => 'Hello']; + + $this->assertStringContainsString('<em><!-- antlers:start ', (string) Antlers::parse($template, $data, true)); + $this->assertSame('<p><em>Hello</em></p>', (string) Antlers::parseWithoutInstrumentation($template, $data, true)); + } + + private function renderWithAndWithoutInstrumentation(array $views): array + { + $this->withFakeViews(); + + foreach ($views as $name => $contents) { + $this->viewShouldReturnRaw($name, $contents); + } + + $data = fn () => [ + 'title' => 'Hello', + 'subtitle' => 'World', + 'show' => false, + 'body' => "one\ntwo", + 'snippet' => '<b>{{ title }}</b>', + 'intro' => new Value('Read <b>{{ title }}</b> now', 'intro', (new Text)->setField(new Field('intro', ['type' => 'text', 'antlers' => true]))), + ]; + + $plain = view('page', $data())->render(); + + Runtime::register(HtmlInstrumentation::make()); + + return [$plain, view('page', $data())->render()]; + } +} diff --git a/tests/View/Instrumentation/MarkerPostProcessingTest.php b/tests/View/Instrumentation/MarkerPostProcessingTest.php new file mode 100644 index 00000000000..2b05b170f9b --- /dev/null +++ b/tests/View/Instrumentation/MarkerPostProcessingTest.php @@ -0,0 +1,201 @@ +<?php + +namespace Tests\View\Instrumentation; + +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Instrumentation\Antlers\ComponentSourceMap; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Instrumentation\InstrumentationManager; +use Statamic\View\Instrumentation\InstrumentationState; +use Tests\TestCase; + +class MarkerPostProcessingTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + GlobalRuntimeState::$isEvaluatingUserData = false; + } + + public function test_the_final_pass_scans_output_it_already_cleaned_only_once() + { + $manager = new CountingInstrumentationManager; + $manager->register(HtmlInstrumentation::make()->attributes('data-antlers')); + [$start, $end, $layer] = $this->markers(); + + $dirty = '<main'.$layer.'>'.$start.'Hello'.$end.'<input value="'.$start.'x'.$end.'"><script>'.$start.'y'.$end.'</script></main>'; + $clean = '<main'.$layer.'>'.$start.'Hello'.$end.'<input value="x"><script>y</script></main>'; + + $this->assertSame($clean, $manager->removeMisplacedMarkers($dirty)); + $this->assertSame(1, $manager->scans); + + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame(1, $manager->scans); + + $this->assertSame($clean, $manager->removeMisplacedMarkers($dirty)); + $this->assertSame(2, $manager->scans); + + $this->assertSame($clean.' ', $manager->removeMisplacedMarkers($clean.' ')); + $this->assertSame(3, $manager->scans); + + $this->assertSame($clean.' ', $manager->removeMisplacedMarkers($clean.' ')); + $this->assertSame(3, $manager->scans); + } + + public function test_the_final_pass_memo_is_scoped_to_the_registered_instrumenters() + { + $manager = new CountingInstrumentationManager; + $manager->register($instrumenter = HtmlInstrumentation::make()); + [$start, $end] = $this->markers(); + + $clean = '<p>'.$start.'Hello'.$end.'</p>'; + + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame(1, $manager->scans); + + $manager->register(HtmlInstrumentation::make()->comments('trace')); + + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame(2, $manager->scans); + + $manager->flush(); + $manager->register($instrumenter); + + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame(3, $manager->scans); + + $manager->flush(); + + $this->assertSame($clean, $manager->removeMisplacedMarkers($clean)); + $this->assertSame(3, $manager->scans); + } + + public function test_the_final_pass_memo_follows_instrumenter_reconfiguration() + { + $manager = new CountingInstrumentationManager; + $manager->register($instrumenter = HtmlInstrumentation::make()); + [$start, $end, $layer] = $this->markers(); + + $html = '<p>'.$start.'Hello'.$end.'</p><input value="x'.$layer.'">'; + + $this->assertSame($html, $manager->removeMisplacedMarkers($html)); + $this->assertSame($html, $manager->removeMisplacedMarkers($html)); + $this->assertSame(1, $manager->scans); + + $instrumenter->attributes('data-antlers'); + + $this->assertSame('<p>'.$start.'Hello'.$end.'</p><input value="x">', $manager->removeMisplacedMarkers($html)); + $this->assertSame(2, $manager->scans); + } + + public function test_the_final_pass_strips_markers_of_instrumenters_registered_only_on_the_parser() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true]]); + + $parser = app(ParserContract::class); + $parser->preparse(HtmlInstrumentation::make()->comments('local')); + + $attrs = '<!-- local:start eyJpZCI6MX0= --> class="a"<!-- local:end 1 --> data-x="<!-- antlers:start eyJpZCI6Mn0= -->1<!-- antlers:end 2 -->"'; + $rendered = (string) $parser->parse('<div {{ attrs }}>x</div><p>{{ title }}</p>', ['attrs' => $attrs, 'title' => 'Hi']); + + $this->assertStringStartsWith('<div class="a" data-x="1">x</div><p><!-- antlers:start ', $rendered); + $this->assertStringContainsString('<!-- local:start ', $rendered); + $this->assertSame( + '<div class="a" data-x="1">x</div><p>Hi</p>', + preg_replace('/<!-- (?:antlers|local):(?:start [A-Za-z0-9+\/=]+|end \d+) -->/', '', $rendered) + ); + } + + public function test_the_final_pass_ends_comments_where_browsers_do() + { + $manager = new CountingInstrumentationManager; + $manager->register(HtmlInstrumentation::make()); + [$start, $end] = $this->markers(); + + $bang = '<!-- note --!><p>'.$start.'x'.$end.'</p><!-- z -->'; + + $this->assertSame($bang, $manager->removeMisplacedMarkers($bang)); + $this->assertSame('<!-- note x --!><p>'.$start.'x'.$end.'</p>', $manager->removeMisplacedMarkers('<!-- note '.$start.'x'.$end.' --!><p>'.$start.'x'.$end.'</p>')); + $this->assertSame('<!-- note x -->', $manager->removeMisplacedMarkers('<!-- note '.$start.'x'.$end.' -->')); + } + + #[DataProvider('splitMarkersProvider')] + public function test_separating_markers_returns_the_plain_value_and_its_instrumented_twin(string $output, string $value, ?string $twin) + { + $manager = new CountingInstrumentationManager; + $manager->register(HtmlInstrumentation::make()->attributes('data-antlers')); + $decode = fn (?string $text) => $text === null ? null : str_replace(['[S]', '[E]', '[L]'], $this->markers(), $text); + + $this->assertSame([$value, $decode($twin)], $manager->splitMarkers($decode($output))); + + if ($twin !== null) { + $this->assertSame($value, $manager->removeMarkers($decode($twin))); + } + } + + public static function splitMarkersProvider(): array + { + return [ + 'plain output is trimmed' => [" \n<b>Hello</b>\n ", '<b>Hello</b>', null], + 'authored comments are not markers' => ['<!-- antlers --> ', '<!-- antlers -->', null], + 'whitespace only' => ["\n\t", '', null], + 'user text resembling a layer' => [' data-antlers="x" text', 'data-antlers="x" text', null], + 'user text resembling a layer after a newline' => ["\n data-antlers=\"x\"", 'data-antlers="x"', null], + 'markers around the value' => [" \n[S]<b[L]>Hello</b>[E]\n ", '<b>Hello</b>', '[S]<b[L]>Hello</b>[E]'], + 'markers inside the value' => ["\n <i>[S]Hi[E]</i> ", '<i>Hi</i>', '<i>[S]Hi[E]</i>'], + 'markers around whitespace only' => [" [S]\n[E] ", '', '[S][E]'], + ]; + } + + public function test_result_cache_keys_change_with_every_input_that_changes_instrumentation() + { + $instrumenter = HtmlInstrumentation::make(); + $method = new ReflectionMethod($instrumenter, 'resultCacheKey'); + $key = fn (string $template = '<p>{{ a }}</p>', ?string $view = 'views/page.antlers.html', bool $root = true) => $method->invoke($instrumenter, $template, $view, $root); + $base = $key(); + + $this->assertSame($base, $key()); + $this->assertSame($base, $method->invoke(HtmlInstrumentation::make()->attributes('data-antlers'), '<p>{{ a }}</p>', 'views/page.antlers.html', true)); + $this->assertNotSame($base, $key('<p>{{ b }}</p>')); + $this->assertNotSame($base, $key(view: 'views/other.antlers.html')); + $this->assertNotSame($base, $key(view: null)); + $this->assertNotSame($base, $key(root: false)); + $this->assertNotSame($base, InstrumentationState::withSourceSeed([2, 0, true], $key)); + $this->assertNotSame($base, InstrumentationState::withSourceSeed([1, 5, true], $key)); + $this->assertNotSame($base, InstrumentationState::withSourceSeed([1, 0, false], $key)); + $this->assertNotSame($base, InstrumentationState::withSourceMap(new ComponentSourceMap('<x-card />'), $key)); + $this->assertNotSame( + InstrumentationState::withSourceMap(new ComponentSourceMap('<x-card />'), $key), + InstrumentationState::withSourceMap(new ComponentSourceMap('<x-card>a</x-card>'), $key) + ); + $this->assertNotSame($key(view: 'a:1'), $key(view: 'a', template: '1')); + $this->assertNotSame($key(view: "a\0b"), $key(view: 'a', template: "\0b")); + } + + private function markers(): array + { + return [ + '<!-- antlers:start '.base64_encode('{"id":1}').' -->', + '<!-- antlers:end 1 -->', + ' data-antlers="W10="', + ]; + } +} + +class CountingInstrumentationManager extends InstrumentationManager +{ + public int $scans = 0; + + protected function cleanMisplacedMarkers(string $html, array $instrumenters): string + { + $this->scans++; + + return parent::cleanMisplacedMarkers($html, $instrumenters); + } +} diff --git a/tests/View/Instrumentation/NocacheInstrumentationTest.php b/tests/View/Instrumentation/NocacheInstrumentationTest.php new file mode 100644 index 00000000000..d833a05d467 --- /dev/null +++ b/tests/View/Instrumentation/NocacheInstrumentationTest.php @@ -0,0 +1,91 @@ +<?php + +namespace Tests\View\Instrumentation; + +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; +use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\NoCache\StringFragment; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Tests\FakesContent; +use Tests\FakesViews; +use Tests\PreventSavingStacheItemsToDisk; +use Tests\TestCase; +use Tests\View\Instrumentation\Concerns\DecodesMarkers; + +class NocacheInstrumentationTest extends TestCase +{ + use DecodesMarkers, FakesContent, FakesViews, PreventSavingStacheItemsToDisk; + + const FIRST = '<main><p>Existing</p><b>Existing</b></main>'; + + const SECOND = '<main><p>Existing</p><b>Updated</b></main>'; + + const UNCACHED_SECOND = '<main><p>Updated</p><b>Updated</b></main>'; + + private string $slug = ''; + + protected function getEnvironmentSetUp($app) + { + parent::getEnvironmentSetUp($app); + + $app['config']->set('cache.default', 'file'); + $app['config']->set('statamic.static_caching.strategy', 'half'); + } + + public function test_nocache_regions_render_their_captured_content_without_reinstrumenting_it() + { + Runtime::register(HtmlInstrumentation::make()); + + [$first, $second] = $this->renderTwice(); + + $this->assertFalse(app(Cacher::class)->hasCachedPage(Request::create('/'.$this->slug))); + + foreach ([[self::FIRST, $first], [self::UNCACHED_SECOND, $second]] as [$plain, $instrumented]) { + $this->assertSame($plain, Runtime::removeMarkers($instrumented)); + $this->assertSame(1, substr_count($instrumented, '<b><!-- antlers:start ')); + + $markers = collect($this->decodedMarkers($instrumented)); + + $this->assertSame(2, $markers->where('expression', 'title')->count()); + $this->assertSame(['default.antlers.html'], $markers->where('expression', 'title')->pluck('view')->unique()->all()); + $this->assertSame([], $markers->pluck('view')->filter(fn ($view) => str_contains((string) $view, 'nocache'))->all()); + } + } + + public function test_string_fragments_keep_their_captured_markers_without_adding_new_ones() + { + $instrumenter = HtmlInstrumentation::make(); + Runtime::register($instrumenter); + $captured = $instrumenter('<p>{{ title }}</p>'); + + $this->assertSame(['title'], $this->markerExpressions($captured)); + + $fragment = fn (string $contents) => (new StringFragment('fragment', $contents, 'antlers.html', ['title' => 'Hello']))->render(); + + $this->assertSame('<p>Hello</p>', $fragment('<p>{{ title }}</p>')); + $this->assertSame(str_replace('{{ title }}', 'Hello', $captured), $fragment($captured)); + $this->assertSame(['title'], $this->markerExpressions($fragment($captured))); + } + + private function renderTwice(): array + { + Cache::flush(); + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '<main><p>{{ title }}</p>{{ nocache }}<b>{{ title }}</b>{{ /nocache }}</main>'); + + $this->slug = 'page-'.Str::lower(Str::random(8)); + $page = $this->createPage($this->slug, ['with' => ['title' => 'Existing']]); + + $first = $this->get('/'.$this->slug)->assertOk()->content(); + + $page->set('title', 'Updated')->saveQuietly(); + + $second = $this->get('/'.$this->slug)->assertOk()->content(); + + return [trim($first), trim($second)]; + } +} diff --git a/tests/View/Instrumentation/PersistentResultCacheTest.php b/tests/View/Instrumentation/PersistentResultCacheTest.php new file mode 100644 index 00000000000..8c0926fd852 --- /dev/null +++ b/tests/View/Instrumentation/PersistentResultCacheTest.php @@ -0,0 +1,272 @@ +<?php + +namespace Tests\View\Instrumentation; + +use Illuminate\Cache\ArrayStore; +use Illuminate\Support\Facades\Cache; +use InvalidArgumentException; +use ReflectionClass; +use ReflectionMethod; +use ReflectionProperty; +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\Facades\Cascade; +use Statamic\View\Antlers\Language\Analyzers\Html\Document; +use Statamic\View\Antlers\Language\Nodes\AntlersNode; +use Statamic\View\Antlers\Language\Parser\ComponentCompiler; +use Statamic\View\Antlers\Language\Parser\DocumentParser; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Antlers\Language\Runtime\RuntimeParser; +use Statamic\View\Antlers\Language\Utilities\CharacterOffsets; +use Statamic\View\Instrumentation\Antlers\TemplateAnalyzer; +use Statamic\View\Instrumentation\HtmlContext; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Tests\Antlers\ParserTestCase; + +class PersistentResultCacheTest extends ParserTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + GlobalRuntimeState::$isEvaluatingUserData = false; + config(['cache.stores.probe' => ['driver' => 'array', 'serialize' => true]]); + RuntimeParser::clearRenderNodeCache(); + $this->resetProcessState(); + } + + public function tearDown(): void + { + $this->resetProcessState(); + + parent::tearDown(); + } + + public function test_config_built_instrumenters_persist_results_and_contexts_across_processes() + { + $config = ['comments' => true, 'prefix' => 'antlers', 'attributes' => ['data-antlers'], 'cache' => 'probe']; + $template = '<div><p>{{ title }}</p><script>{{ js }}</script><table>{{ rows }}<tr><td>{{ cell }}</td></tr>{{ /rows }}</table></div>'; + + $instrumenter = HtmlInstrumentation::fromConfig($config); + $first = $instrumenter->instrument($template); + $contexts = HtmlInstrumentation::annotationsFor($first); + $key = $this->storeKeyFor($instrumenter, $template); + + $this->assertStringContainsString('<!-- antlers:start ', $first); + $this->assertCount(5, $contexts); + $this->assertCount(1, $this->storedEntries()); + $this->assertNull($instrumenter->lastCacheStoreException()); + + $stored = Cache::store('probe')->get($key); + + $this->assertSame($first, $stored[0]); + $this->assertCount(5, $stored[2]); + $this->assertLessThan(5, count($stored[1])); + + $this->resetProcessState(); + $this->assertNull(HtmlInstrumentation::annotationsFor($first)); + + $fresh = HtmlInstrumentation::fromConfig($config); + + $this->assertSame($first, $fresh->instrument($template)); + + $restored = HtmlInstrumentation::annotationsFor($first); + + $this->assertSame( + array_map(fn ($context) => $context?->toArray(), $contexts), + array_map(fn ($context) => $context?->toArray(), $restored) + ); + $this->assertSame([true, false, false], [ + $restored[0]->isSafeForHtmlComments(), + $restored[1]->isSafeForHtmlComments(), + $restored[2]->isSafeForHtmlComments(), + ]); + + Cache::store('probe')->put($key, ['tampered', $stored[1], $stored[2]], 60); + $this->resetProcessState(); + + $this->assertSame('tampered', HtmlInstrumentation::fromConfig($config)->instrument($template)); + $this->assertCount(5, HtmlInstrumentation::annotationsFor('tampered')); + } + + public function test_ad_hoc_instrumenters_skip_callbacks_and_disabled_caches_leave_the_store_alone() + { + $template = '<p>{{ title }}</p>'; + $config = ['comments' => true, 'prefix' => 'antlers', 'cache' => 'probe']; + + HtmlInstrumentation::make()->comments()->instrument($template); + $this->assertCount(0, $this->storedEntries()); + + HtmlInstrumentation::fromConfig(['comments' => true, 'prefix' => 'antlers', 'cache' => false])->instrument($template); + $this->assertCount(0, $this->storedEntries()); + + $reporting = HtmlInstrumentation::fromConfig($config)->onSkip(fn () => null); + $reporting->instrument('<title>{{ title }}</title>'); + $this->assertCount(0, $this->storedEntries()); + + HtmlInstrumentation::instrumentTogether($template, [HtmlInstrumentation::fromConfig($config), HtmlInstrumentation::make()->comments('other')]); + $this->assertCount(0, $this->storedEntries()); + + $together = HtmlInstrumentation::instrumentTogether($template, [ + HtmlInstrumentation::fromConfig($config), + HtmlInstrumentation::fromConfig(['comments' => true, 'prefix' => 'other', 'cache' => 'probe']), + ]); + + $this->assertCount(1, $this->storedEntries()); + $this->assertCount(1, HtmlInstrumentation::annotationsFor($together)); + $this->assertSame(1, substr_count($together, '<!-- antlers:start ')); + $this->assertSame(1, substr_count($together, '<!-- other:start ')); + } + + public function test_a_missing_cache_store_degrades_to_the_in_process_memo() + { + $template = '<p>{{ title }}</p>'; + $instrumenter = HtmlInstrumentation::fromConfig(['comments' => true, 'prefix' => 'antlers', 'cache' => 'missing']); + + $first = $instrumenter->instrument($template); + + $this->assertStringContainsString('<!-- antlers:start ', $first); + $this->assertInstanceOf(InvalidArgumentException::class, $instrumenter->lastCacheStoreException()); + $this->assertCount(1, HtmlInstrumentation::annotationsFor($first)); + + $memo = new ReflectionProperty(HtmlInstrumentation::class, 'results'); + $memo->setValue(null, array_map(fn ($entry) => ['memoized', $entry[1], $entry[2]], $memo->getValue())); + + $this->assertSame('memoized', $instrumenter->instrument($template)); + } + + public function test_malformed_store_entries_are_recomputed_and_replaced() + { + $config = ['comments' => true, 'prefix' => 'antlers', 'cache' => 'probe']; + $template = '<p>{{ title }}</p>'; + $instrumenter = HtmlInstrumentation::fromConfig($config); + $key = $this->storeKeyFor($instrumenter, $template); + + $malformedEntries = [ + 'garbage', + [42], + ['text', 'stacks', 'packed'], + ['text', [], [[1, 2]]], + ['text', [['p']], [['element-content', 'p', null, 7, null, 0]]], + ['text', [['p']], [['element-content', 'p', null, 0, null]]], + ['text', [['p']], [['kind' => 'element-content', 'element' => 'p', 'attribute' => null, 'stack' => 0, 'offset' => null, 'flags' => 0]]], + ['text', [['p']], [[7, 'p', null, 0, null, 0]]], + ['text', [['p']], [['element-content', 'p', null, 0, null, '0']]], + ['text', [[1]], [['element-content', 'p', null, 0, null, 0]]], + ]; + + foreach ($malformedEntries as $malformed) { + Cache::store('probe')->put($key, $malformed, 60); + $this->resetProcessState(); + + $result = HtmlInstrumentation::fromConfig($config)->instrument($template); + + $this->assertStringContainsString('<!-- antlers:start ', $result, json_encode($malformed)); + $this->assertSame($result, Cache::store('probe')->get($key)[0]); + $this->assertCount(1, HtmlInstrumentation::annotationsFor($result)); + } + + Cache::store('probe')->put($key, ['text', null, null], 60); + $this->resetProcessState(); + + $this->assertSame('text', HtmlInstrumentation::fromConfig($config)->instrument($template)); + $this->assertNull(HtmlInstrumentation::annotationsFor('text')); + } + + public function test_the_configured_instrumenter_uses_the_store_named_in_the_config() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'comments' => true, 'prefix' => 'antlers', 'cache' => 'probe']]); + Cascade::set('views', ['pages/home.antlers.html' => [], 'pages/about.antlers.html' => []]); + + $rendered = (string) app(ParserContract::class)->parseView('pages/home.antlers.html', '<p>{{ title }}</p>', ['title' => 'Hello']); + + $this->assertStringContainsString('<!-- antlers:start ', $rendered); + $this->assertCount(1, $this->storedEntries()); + + $rendered = (string) app(ParserContract::class)->parse('<p>{{ title }} #1</p>', ['title' => 'Hello'], true); + + $this->assertStringContainsString('<!-- antlers:start ', $rendered); + $this->assertCount(1, $this->storedEntries()); + + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'comments' => true, 'prefix' => 'antlers', 'cache' => false]]); + app(ParserContract::class)->parseView('pages/about.antlers.html', '<p>{{ subtitle }}</p>', ['subtitle' => 'Hello']); + + $this->assertCount(1, $this->storedEntries()); + } + + public function test_store_hits_hand_the_runtime_the_same_contexts_as_a_fresh_analysis() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'comments' => true, 'prefix' => 'antlers', 'cache' => 'probe']]); + $template = '<div><p>{{ title }}</p><script>{{ js }}</script>{{ if show }}<b>{{ title }}</b>{{ /if }}</div>'; + $data = ['title' => 'Hello', 'js' => '1', 'show' => true]; + $parser = app(ParserContract::class); + + $first = (string) $parser->parse($template, $data); + $analysed = $this->contextsOf($parser); + + $this->resetProcessState(); + RuntimeParser::clearRenderNodeCache(); + + $second = (string) $parser->parse($template, $data); + $restored = $this->contextsOf($parser); + + $this->assertSame($first, $second); + $this->assertCount(5, $analysed); + $this->assertSame($analysed, $restored); + $this->assertSame(['element-content', 'raw-text', 'element-content', 'element-content', 'element-content'], array_column($restored, 'kind')); + } + + public function test_development_builds_stamp_every_directory_the_analysis_reads() + { + $directories = array_map( + 'realpath', + (new ReflectionMethod(HtmlInstrumentation::class, 'analysisSourceDirectories'))->invoke(null) + ); + + $this->assertNotContains(false, $directories); + + foreach ([ + HtmlInstrumentation::class, + TemplateAnalyzer::class, + Document::class, + DocumentParser::class, + ComponentCompiler::class, + AntlersNode::class, + CharacterOffsets::class, + ] as $class) { + $this->assertContains(dirname((new ReflectionClass($class))->getFileName()), $directories, $class); + } + } + + private function contextsOf(RuntimeParser $parser): array + { + $documentParser = (new ReflectionProperty(RuntimeParser::class, 'documentParser'))->getValue($parser); + $contexts = []; + + foreach ($documentParser->getNodes() as $node) { + if ($node instanceof AntlersNode) { + $this->assertInstanceOf(HtmlContext::class, $node->htmlContext); + $contexts[] = ['kind' => $node->htmlContext->kind, 'stack' => $node->htmlContext->elementStack, 'safe' => $node->htmlContext->isSafeForHtmlComments()]; + } + } + + return $contexts; + } + + private function storeKeyFor(HtmlInstrumentation $instrumenter, string $template): string + { + $resultKey = (new ReflectionMethod($instrumenter, 'resultCacheKey'))->invoke($instrumenter, $template, GlobalRuntimeState::$currentExecutionFile, true); + + return (new ReflectionMethod($instrumenter, 'cacheStoreKey'))->invoke($instrumenter, $resultKey); + } + + private function storedEntries(): array + { + return (new ReflectionProperty(ArrayStore::class, 'storage'))->getValue(Cache::store('probe')->getStore()); + } + + private function resetProcessState(): void + { + (new ReflectionProperty(HtmlInstrumentation::class, 'results'))->setValue(null, []); + HtmlInstrumentation::flushAnnotations(); + } +} diff --git a/tests/View/Instrumentation/PostProcessingTagInstrumentationTest.php b/tests/View/Instrumentation/PostProcessingTagInstrumentationTest.php new file mode 100644 index 00000000000..da8dc97820f --- /dev/null +++ b/tests/View/Instrumentation/PostProcessingTagInstrumentationTest.php @@ -0,0 +1,74 @@ +<?php + +namespace Tests\View\Instrumentation; + +use PHPUnit\Framework\Attributes\DataProvider; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Tests\Antlers\ParserTestCase; +use Tests\View\Instrumentation\Concerns\DecodesMarkers; +use Tests\View\Instrumentation\Concerns\InteractsWithRuntimeConfigurations; + +class PostProcessingTagInstrumentationTest extends ParserTestCase +{ + use DecodesMarkers, InteractsWithRuntimeConfigurations; + + #[DataProvider('templates')] + public function test_tags_that_post_process_their_body_render_the_same_with_instrumentation(string $template, string $contains) + { + $data = ['title' => 'Hello there world']; + $parser = $this->parser($data, true); + $parser->setRuntimeConfiguration($this->runtimeConfiguration()); + + $plain = (string) $parser->parse($template, $data); + + $this->assertStringContainsString($contains, $plain); + + Runtime::register(HtmlInstrumentation::make()); + + $instrumented = (string) $parser->parse($template, $data); + + $this->assertNotSame($plain, $instrumented); + $this->assertNotEmpty($this->markerExpressions($instrumented)); + $this->assertSame($plain, Runtime::removeMarkers($instrumented)); + } + + public static function templates(): array + { + return [ + 'markdown' => [ + "<div>{{ markdown }}# {{ title }}\n\nSome *text* {{ title }}{{ /markdown }}</div>", + "<h1>Hello there world</h1>\n<p>Some <em>text</em> Hello there world</p>", + ], + 'indented markdown' => [ + "<div>{{ markdown:indent }}\n # {{ title }}\n\n Some *text* {{ title }}\n{{ /markdown:indent }}</div>", + '<h1>Hello there world</h1>', + ], + 'widont' => [ + '<p>{{ widont }}Some words {{ title }}{{ /widont }}</p>', + '<p>Some words {{ title&nbsp;}}</p>', + ], + ]; + } + + public function test_obfuscated_content_never_contains_marker_text() + { + $data = ['email' => 'a@b.co']; + $template = '<p>{{ obfuscate }}{{ email }}{{ /obfuscate }}</p>'; + $parser = $this->parser($data, true); + $parser->setRuntimeConfiguration($this->runtimeConfiguration()); + + $plain = (string) $parser->parse($template, $data); + + $this->assertSame('<p>{{ email }}</p>', html_entity_decode($plain)); + + Runtime::register(HtmlInstrumentation::make()); + + $instrumented = (string) $parser->parse($template, $data); + $stripped = Runtime::removeMarkers($instrumented); + + $this->assertNotEmpty($this->markerExpressions($instrumented)); + $this->assertNotSame($stripped, $instrumented); + $this->assertSame('<p>{{ email }}</p>', html_entity_decode($stripped)); + } +} diff --git a/tests/View/Instrumentation/ResponseInstrumentationTest.php b/tests/View/Instrumentation/ResponseInstrumentationTest.php new file mode 100644 index 00000000000..b29c5fad505 --- /dev/null +++ b/tests/View/Instrumentation/ResponseInstrumentationTest.php @@ -0,0 +1,105 @@ +<?php + +namespace Tests\View\Instrumentation; + +use Illuminate\Foundation\Http\Events\RequestHandled; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Route; +use InvalidArgumentException; +use Statamic\Contracts\View\Antlers\Parser as ParserContract; +use Statamic\View\Instrumentation\RemoveMisplacedMarkersFromResponse; +use Tests\FakesViews; +use Tests\PreventSavingStacheItemsToDisk; +use Tests\TestCase; + +class ResponseInstrumentationTest extends TestCase +{ + use FakesViews, PreventSavingStacheItemsToDisk; + + protected function resolveApplicationConfiguration($app) + { + parent::resolveApplicationConfiguration($app); + + $app->booted(function () { + Route::get('/instrumented-page', fn () => view('blade-page', ['title' => 'Hello'])); + Route::get('/instrumented-json', fn () => response()->json(['html' => view('card', ['title' => 'Hello'])->render()])); + }); + } + + private function fakeViews(bool $instrumented) + { + config(['statamic.antlers.instrumentation' => ['enabled' => $instrumented]]); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('card', '<em>{{ title }}</em>'); + $this->viewShouldReturnRaw( + 'blade-page', + '<title>@include("card")</title><textarea>@include("card")</textarea><input value="@include(\'card\')"><!-- @include("card") --><div>@include("card")</div>', + 'blade.php' + ); + } + + public function test_responses_that_are_not_html_are_left_alone() + { + $this->fakeViews(true); + + $this->assertStringContainsString('<em><!-- antlers:start ', $this->get('/instrumented-json')->assertOk()->json('html')); + } + + public function test_responses_are_untouched_without_instrumentation() + { + $this->fakeViews(false); + + $this->assertSame( + '<title><em>Hello</em></title><textarea><em>Hello</em></textarea><input value="<em>Hello</em>"><!-- <em>Hello</em> --><div><em>Hello</em></div>', + $this->get('/instrumented-page')->assertOk()->getContent() + ); + } + + public function test_cleaning_misplaced_markers_updates_the_content_length() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true]]); + $marker = '<!-- antlers:start '.base64_encode('{"id":1}').' -->'; + $body = '<input value="'.$marker.'Hello<!-- antlers:end 1 -->"><p>'.$marker.'Hello<!-- antlers:end 1 --></p>'; + $response = new Response($body, 200, ['Content-Type' => 'text/html', 'Content-Length' => strlen($body)]); + + app(RemoveMisplacedMarkersFromResponse::class)->handle(new RequestHandled(Request::create('/'), $response)); + + $this->assertSame('<input value="Hello"><p>'.$marker.'Hello<!-- antlers:end 1 --></p>', $response->getContent()); + $this->assertSame((string) strlen($response->getContent()), $response->headers->get('Content-Length')); + } + + public function test_cleaning_misplaced_markers_keeps_the_original_view_and_a_matching_etag() + { + $this->fakeViews(true); + + $this->get('/instrumented-page')->assertOk()->assertViewIs('blade-page')->assertViewHas('title', 'Hello'); + + $marker = '<!-- antlers:start '.base64_encode('{"id":1}').' -->'; + $body = '<input value="'.$marker.'Hello<!-- antlers:end 1 -->">'; + $response = (new Response($body, 200, ['Content-Type' => 'text/html']))->setEtag(md5($body)); + $unrelated = (new Response($body, 200, ['Content-Type' => 'text/html']))->setEtag('v1'); + + app(RemoveMisplacedMarkersFromResponse::class)->handle(new RequestHandled(Request::create('/'), $response)); + app(RemoveMisplacedMarkersFromResponse::class)->handle(new RequestHandled(Request::create('/'), $unrelated)); + + $this->assertSame('<input value="Hello">', $response->getContent()); + $this->assertSame('"'.md5('<input value="Hello">').'"', $response->getEtag()); + $this->assertSame('"v1"', $unrelated->getEtag()); + } + + public function test_invalid_configuration_is_ignored_by_the_listener_but_still_fails_rendering() + { + config(['statamic.antlers.instrumentation' => ['enabled' => true, 'prefix' => 'bad -->']]); + $body = '<input value="<!-- antlers:start eyJpZCI6MX0= -->Hello<!-- antlers:end 1 -->">'; + $response = new Response($body, 200, ['Content-Type' => 'text/html']); + + app(RemoveMisplacedMarkersFromResponse::class)->handle(new RequestHandled(Request::create('/'), $response)); + + $this->assertSame($body, $response->getContent()); + + $this->expectException(InvalidArgumentException::class); + app(ParserContract::class); + } +} diff --git a/tests/View/Instrumentation/SlotInstrumentationTest.php b/tests/View/Instrumentation/SlotInstrumentationTest.php new file mode 100644 index 00000000000..5698e62caf9 --- /dev/null +++ b/tests/View/Instrumentation/SlotInstrumentationTest.php @@ -0,0 +1,115 @@ +<?php + +namespace Tests\View\Instrumentation; + +use RuntimeException; +use Statamic\Tags\Tags; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Slot; +use Tests\Antlers\ParserTestCase; +use Tests\FakesViews; +use Tests\View\Instrumentation\Concerns\DecodesMarkers; + +class SlotInstrumentationTest extends ParserTestCase +{ + use DecodesMarkers, FakesViews; + + public function test_rendering_keeps_markers_while_values_and_serialized_forms_do_not() + { + $instrumenter = HtmlInstrumentation::make(); + Runtime::register($instrumenter); + $rendered = str_replace('{{ title }}', 'Hello', $instrumenter("\n <p>{{ title }}</p> \n")); + $slot = (new Slot(fn (array $data) => $rendered, ['title' => 'Hello']))->named('body'); + + $this->assertSame(['title'], $this->markerExpressions($rendered)); + $this->assertSame(trim($rendered), $slot->render()); + $this->assertSame(trim($rendered), Slot::output($slot)); + $this->assertSame('<p>Hello</p>', $slot->toHtml()); + $this->assertSame('<p>Hello</p>', (string) $slot); + $this->assertSame(['name' => 'body', 'rendered' => '<p>Hello</p>'], $slot->__serialize()); + } + + public function test_whitespace_around_edge_markers_is_trimmed_like_plain_output() + { + $instrumenter = HtmlInstrumentation::make(); + Runtime::register($instrumenter); + $inner = str_replace('{{ body }}', 'Hello', substr($instrumenter('<div>{{ body }}</div>'), strlen('<div>'), -strlen('</div>'))); + $marked = " \n".$inner."\n "; + $slot = new Slot(fn () => $marked); + + $this->assertStringStartsWith('<!-- antlers:start ', $inner); + $this->assertStringEndsWith(' -->', $inner); + $this->assertSame($inner, $slot->render()); + $this->assertSame('Hello', (string) $slot); + $this->assertSame(['name' => null, 'rendered' => 'Hello'], $slot->__serialize()); + } + + public function test_pre_rendered_slots_restore_as_static_marker_free_content() + { + $instrumenter = HtmlInstrumentation::make(); + Runtime::register($instrumenter); + $rendered = str_replace('{{ title }}', 'Hello', $instrumenter('<p>{{ title }}</p>')); + $slot = (new Slot(fn () => $rendered))->named('footer'); + + $restored = unserialize(serialize($slot)); + + $this->assertInstanceOf(Slot::class, $restored); + $this->assertSame('<p>Hello</p>', $restored->render()); + $this->assertSame('<p>Hello</p>', (string) $restored); + $this->assertSame(['name' => 'footer', 'rendered' => '<p>Hello</p>'], $restored->__serialize()); + + try { + $restored->render(['extra' => true]); + $this->fail('Expected a static slot to reject props.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('[footer] slot has already been rendered and cached', $exception->getMessage()); + } + } + + public function test_output_renders_slots_with_props_and_escapes_anything_else() + { + $slot = (new Slot(fn (array $data) => '<i>'.$data['greeting'].' '.$data['title'].' '.$data['params']['x'].'</i>', ['title' => 'Hello'])) + ->withParams(['x' => 1]); + + $this->assertSame('<i>Hi Hello 1</i>', Slot::output($slot, ['greeting' => 'Hi'])); + $this->assertSame('a &lt;b&gt;', Slot::output('a <b>')); + } + + public function test_antlers_slots_restored_from_a_cache_keep_the_slot_contract() + { + $this->app['statamic.tags']['capture_slot'] = CaptureSlotTag::class; + $this->withFakeViews(); + $this->viewShouldReturnRaw('card', '<div>{{ capture_slot }}{{ slot }}</div>'); + $this->viewShouldReturnRaw('page', '<section>{{ include:card }}<b>{{ title }}</b>{{ /include:card }}</section>'); + Runtime::register(HtmlInstrumentation::make()); + + $rendered = view('page', ['title' => 'Hello'])->render(); + $slot = CaptureSlotTag::$slot; + + $this->assertInstanceOf(Slot::class, $slot); + $this->assertSame('<section><div><b>Hello</b></div></section>', Runtime::removeMarkers($rendered)); + $this->assertSame(['title'], $this->markerExpressions($slot->render())); + $this->assertSame('<b>Hello</b>', (string) $slot); + + $restored = unserialize(serialize($slot)); + + $this->assertNotSame($slot, $restored); + $this->assertSame(['title'], $this->markerExpressions($restored->render())); + $this->assertSame('<b>Hello</b>', Runtime::removeMarkers($restored->render())); + $this->assertSame('<b>Hello</b>', (string) $restored); + $this->assertSame('<b>Hello</b>', $restored->toHtml()); + } +} + +class CaptureSlotTag extends Tags +{ + public static ?Slot $slot = null; + + public function index() + { + static::$slot = $this->context->raw('slot'); + + return ''; + } +} diff --git a/tests/View/Instrumentation/Support/RecordingLegacyTracer.php b/tests/View/Instrumentation/Support/RecordingLegacyTracer.php new file mode 100644 index 00000000000..6689c09c906 --- /dev/null +++ b/tests/View/Instrumentation/Support/RecordingLegacyTracer.php @@ -0,0 +1,33 @@ +<?php + +namespace Tests\View\Instrumentation\Support; + +use Statamic\View\Antlers\Language\Nodes\AbstractNode; +use Statamic\View\Antlers\Language\Runtime\Tracing\RuntimeTracerContract; + +class RecordingLegacyTracer implements RuntimeTracerContract +{ + public array $entered = []; + + public array $exited = []; + + public array $exits = []; + + public int $completions = 0; + + public function onEnter(AbstractNode $node) + { + $this->entered[] = $node; + } + + public function onExit(AbstractNode $node, $runtimeContent) + { + $this->exited[] = $node; + $this->exits[] = [$node, $runtimeContent]; + } + + public function onRenderComplete() + { + $this->completions++; + } +} diff --git a/tests/View/Instrumentation/Support/RecordingSpanTracer.php b/tests/View/Instrumentation/Support/RecordingSpanTracer.php new file mode 100644 index 00000000000..3d680bb213f --- /dev/null +++ b/tests/View/Instrumentation/Support/RecordingSpanTracer.php @@ -0,0 +1,69 @@ +<?php + +namespace Tests\View\Instrumentation\Support; + +use Illuminate\Support\Collection; +use Statamic\View\Instrumentation\Span; +use Statamic\View\Instrumentation\TracerContract; + +class RecordingSpanTracer implements TracerContract +{ + public array $events = []; + + protected int $handles = 0; + + public function onEnter(Span $span): mixed + { + $this->handles++; + $handle = 'handle-'.$this->handles; + $this->events[] = ['type' => 'enter', 'span' => $span, 'handle' => $handle]; + + return $handle; + } + + public function onExit(Span $span, mixed $handle, mixed $output): void + { + $this->events[] = ['type' => 'exit', 'span' => $span, 'handle' => $handle, 'output' => $output]; + } + + public function onRenderComplete(): void + { + $this->events[] = ['type' => 'complete']; + } + + public function eventsOfType(string $type): Collection + { + return collect($this->events)->where('type', $type); + } + + public function indexOfEvent(string $type, callable $matchesSpan): ?int + { + foreach ($this->eventsOfType($type) as $index => $event) { + if ($matchesSpan($event['span'])) { + return $index; + } + } + + return null; + } + + public function exitFor(Span $span): ?array + { + $index = $this->indexOfEvent('exit', fn (Span $candidate) => $candidate === $span); + + if ($index === null) { + return null; + } + + return ['index' => $index] + $this->events[$index]; + } + + public function lastEventType(): ?string + { + if (empty($this->events)) { + return null; + } + + return $this->events[array_key_last($this->events)]['type']; + } +} diff --git a/tests/View/Instrumentation/TagBodyInstrumentationTest.php b/tests/View/Instrumentation/TagBodyInstrumentationTest.php new file mode 100644 index 00000000000..5f5b7aa8d68 --- /dev/null +++ b/tests/View/Instrumentation/TagBodyInstrumentationTest.php @@ -0,0 +1,325 @@ +<?php + +namespace Tests\View\Instrumentation; + +use PHPUnit\Framework\Attributes\DataProvider; +use Statamic\Contracts\View\Instrumentation\ComposesRenderedOutput; +use Statamic\Facades\Antlers; +use Statamic\Tags\Tags; +use Statamic\View\Antlers\Language\Facades\Runtime; +use Statamic\View\Antlers\Language\Runtime\RuntimeParser; +use Statamic\View\Cascade; +use Statamic\View\Instrumentation\HtmlInstrumentation; +use Statamic\View\Instrumentation\InstrumentationState; +use Statamic\View\Instrumentation\TagBody; +use Tests\Antlers\ParserTestCase; +use Tests\View\Instrumentation\Concerns\DecodesMarkers; +use Tests\View\Instrumentation\Concerns\InteractsWithRuntimeConfigurations; + +class TagBodyInstrumentationTest extends ParserTestCase +{ + use DecodesMarkers, InteractsWithRuntimeConfigurations; + + protected function setUp(): void + { + parent::setUp(); + + $this->app['statamic.tags']['body'] = BodyProbeTag::class; + $this->app['statamic.tags']['composer'] = BodyComposerTag::class; + BodyProbeTag::$seen = []; + BodyComposerTag::$seen = []; + } + + private function render(string $template, ?HtmlInstrumentation $instrumenter, array $data = ['title' => 'Hello']): string + { + RuntimeParser::clearRenderNodeCache(); + $parser = $this->parser($data, true); + $parser->cascade(app(Cascade::class)); + $parser->setRuntimeConfiguration($this->runtimeConfiguration()); + + if ($instrumenter === null) { + return (string) $parser->parse($template, $data); + } + + Runtime::register($instrumenter); + + try { + return (string) $parser->parse($template, $data); + } finally { + Runtime::forget($instrumenter); + } + } + + private function assertNeutral(string $template, HtmlInstrumentation $instrumenter): string + { + $plain = $this->render($template, null); + $instrumented = $this->render($template, $instrumenter); + + $this->assertSame($plain, HtmlInstrumentation::removeMarkers($instrumented, [$instrumenter]), $template); + $this->assertFalse(InstrumentationState::suppressed()); + + return $instrumented; + } + + private function titleMarkers(string $output, string $mode = 'comments'): array + { + if ($mode === 'attributes') { + preg_match_all('/ data-antlers="([^"]+)"/', $output, $matches); + + $entries = []; + + foreach ($matches[1] as $payload) { + foreach (HtmlInstrumentation::decode($payload) ?? [] as $entry) { + $entries[] = $entry; + } + } + } else { + $entries = $this->decodedMarkers($output); + } + + return collect($entries)->where('expression', 'title')->values()->all(); + } + + public static function instrumenters(): array + { + return [ + 'comments' => ['comments', fn () => HtmlInstrumentation::make()], + 'attributes' => ['attributes', fn () => HtmlInstrumentation::make()->withoutComments()->attributes('data-antlers')], + 'comments and attributes' => ['comments', fn () => HtmlInstrumentation::make()->attributes('data-antlers')], + ]; + } + + #[DataProvider('instrumenters')] + public function test_tags_see_marker_free_content_and_strings_while_untouched_renders_print_markers(string $mode, callable $make) + { + $template = '<div>{{ body:inspect }}<b>{{ title }}</b>{{ /body:inspect }}</div>'; + $output = $this->assertNeutral($template, $make()); + + $this->assertSame('<b>{{ title }}</b>', BodyProbeTag::$seen['content']); + $this->assertSame('<b>Hello</b>', BodyProbeTag::$seen['string']); + + $markers = $this->titleMarkers($output, $mode); + + $this->assertCount(1, $markers, $output); + $this->assertSame(mb_strpos($template, '{{ title }}'), $markers[0]['start']); + $this->assertSame('b', $markers[0]['element']); + } + + #[DataProvider('instrumenters')] + public function test_trimmed_bodies_keep_their_markers(string $mode, callable $make) + { + $template = "<ul>{{ body:trimmed }}\n <li>{{ title }}</li>\n{{ /body:trimmed }}</ul>"; + $output = $this->assertNeutral($template, $make()); + + $this->assertSame('<li>{{ title }}</li>', BodyProbeTag::$seen['content']); + $this->assertSame(2, substr_count($output, '<li')); + + $markers = $this->titleMarkers($output, $mode); + + $this->assertCount(2, $markers, $output); + $this->assertSame([mb_strpos($template, '{{ title }}')], collect($markers)->pluck('start')->unique()->all()); + } + + #[DataProvider('instrumenters')] + public function test_wrapped_bodies_keep_their_markers_without_instrumenting_the_wrapper(string $mode, callable $make) + { + $template = '<div>{{ body:wrapped }}<b>{{ title }}</b>{{ /body:wrapped }}</div>'; + $output = $this->assertNeutral($template, $make()); + + $this->assertStringContainsString('<i><b', $output); + $this->assertStringNotContainsString('<i data-antlers', $output); + + $markers = $this->titleMarkers($output, $mode); + + $this->assertCount(1, $markers, $output); + $this->assertSame(mb_strpos($template, '{{ title }}'), $markers[0]['start']); + } + + #[DataProvider('instrumenters')] + public function test_renders_read_as_strings_print_marker_free_unless_the_tag_asks_for_markers(string $mode, callable $make) + { + $composed = $this->assertNeutral('<div>{{ body:composed }}<b>{{ title }}</b>{{ /body:composed }}</div>', $make()); + + $this->assertStringContainsString('<section><b>Hello</b></section>', $composed); + $this->assertCount(0, $this->titleMarkers($composed, $mode), $composed); + + $transformed = $this->assertNeutral('<div>{{ body:transformed }}<b>{{ title }}</b>{{ /body:transformed }}</div>', $make()); + + $this->assertStringContainsString('<B>HELLO</B>', $transformed); + $this->assertCount(0, $this->titleMarkers($transformed, $mode), $transformed); + + $template = '<div>{{ composer }}<b>{{ title }}</b>{{ /composer }}</div>'; + $composer = $this->assertNeutral($template, $make()); + $markers = $this->titleMarkers($composer, $mode); + + $this->assertStringContainsString('<section><b', $composer); + $this->assertCount(1, $markers, $composer); + $this->assertSame(mb_strpos($template, '{{ title }}'), $markers[0]['start']); + $this->assertNotSame('<b>{{ title }}</b>', BodyComposerTag::$seen['content']); + $this->assertSame('<b>{{ title }}</b>', HtmlInstrumentation::removeMarkers(BodyComposerTag::$seen['content'], [$make()])); + } + + public function test_string_templates_rendered_by_tags_keep_markers_only_when_returned_untouched() + { + $object = $this->assertNeutral('<div>{{ body:fresh_object }}</div>', HtmlInstrumentation::make()); + $markers = $this->titleMarkers($object); + + $this->assertCount(1, $markers, $object); + $this->assertArrayNotHasKey('view', $markers[0]); + $this->assertSame('u', $markers[0]['element']); + + $string = $this->assertNeutral('<div>{{ body:fresh_string }}</div>', HtmlInstrumentation::make()); + + $this->assertStringContainsString('<u>Hello</u>', $string); + $this->assertCount(0, $this->titleMarkers($string), $string); + } + + public function test_bodies_in_unsafe_contexts_print_marker_free_output() + { + foreach (['inspect', 'trimmed', 'composed'] as $method) { + $template = '<textarea>{{ body:'.$method.' }}<b>{{ title }}</b>{{ /body:'.$method.' }}</textarea>'; + + $this->assertSame($this->render($template, null), $this->render($template, HtmlInstrumentation::make()), $method); + } + $this->assertFalse(InstrumentationState::suppressed()); + } + + public function test_sections_and_yield_defaults_keep_their_markers() + { + $template = '{{ section:main }}<b>{{ title }}</b>{{ /section:main }}<div>{{ yield:main }}</div><p>{{ yield:other }}<i>{{ title }}</i>{{ /yield:other }}</p>'; + $output = $this->assertNeutral($template, HtmlInstrumentation::make()); + + $this->assertStringContainsString('<b><!-- antlers:start ', $output); + $this->assertStringContainsString('<i><!-- antlers:start ', $output); + $this->assertCount(2, $this->titleMarkers($output), $output); + $this->assertSame(1, substr_count($output, '-->Hello<!-- antlers:end 1 --></b>')); + } + + public function test_the_active_tag_body_is_released_when_a_tag_throws() + { + Runtime::register($instrumenter = HtmlInstrumentation::make()); + + try { + try { + $this->render('<div>{{ body:throws }}<b>{{ title }}</b>{{ /body:throws }}</div>', HtmlInstrumentation::make()); + $this->fail('Expected the tag to throw.'); + } catch (\RuntimeException $exception) { + $this->assertSame('body failed', $exception->getMessage()); + } + + $this->assertNull(InstrumentationState::activeTagBody()); + } finally { + Runtime::forget($instrumenter); + } + + $output = $this->assertNeutral('<div><b>{{ title }}</b></div>', HtmlInstrumentation::make()); + + $this->assertCount(1, $this->titleMarkers($output), $output); + } + + public function test_tag_bodies_align_derived_text_with_their_markers() + { + $instrumenter = HtmlInstrumentation::make(); + $start = '<!-- antlers:start YWJj -->'; + $end = '<!-- antlers:end 1 -->'; + $body = TagBody::from(" {$start}{{ a }}{$end} ", [$instrumenter]); + + $this->assertSame(' {{ a }} ', $body->clean); + $this->assertSame(" {$start}{{ a }}{$end} ", $body->align(' {{ a }} ')); + $this->assertSame("{$start}{{ a }}{$end}", $body->align('{{ a }}')); + $this->assertSame("<i> {$start}{{ a }}{$end} </i>", $body->align('<i> {{ a }} </i>')); + $this->assertSame("{$start}{{ a }}{$end}", $body->align("{$start}{{ a }}{$end}")); + $this->assertSame('<i> {{ a }} </i><i> {{ a }} </i>', $body->align('<i> {{ a }} </i><i> {{ a }} </i>')); + $this->assertNull($body->align('{{ b }}')); + $this->assertNull($body->align('')); + + $plain = TagBody::from('<b>{{ a }}</b>', [$instrumenter]); + + $this->assertSame('<b>{{ a }}</b>', $plain->align('<b>{{ a }}</b>')); + $this->assertSame('{{ a }}', $plain->align('{{ a }}')); + $this->assertNull($plain->align('<u>{{ b }}</u>')); + } + + public function test_sliced_tag_bodies_only_keep_the_marker_pairs_they_contain() + { + $first = ['<!-- antlers:start YQ== -->', '<!-- antlers:end 1 -->']; + $second = ['<!-- antlers:start Yg== -->', '<!-- antlers:end 2 -->']; + $body = TagBody::from("<b>{$first[0]}{{ a }}{$first[1]}{$second[0]}{{ b }}{$second[1]}</b>", [HtmlInstrumentation::make()]); + + $this->assertSame('<b>{{ a }}{{ b }}</b>', $body->clean); + $this->assertSame("{$first[0]}{{ a }}{$first[1]}", $body->align('{{ a }}')); + $this->assertSame("{$second[0]}{{ b }}{$second[1]}", $body->align('{{ b }}')); + $this->assertSame("{$first[0]}{{ a }}{$first[1]}{$second[0]}{{ b }}{$second[1]}</b>", $body->align('{{ a }}{{ b }}</b>')); + $this->assertSame("a }}{$second[0]}{{ b }}{$second[1]}", $body->align('a }}{{ b }}')); + $this->assertSame('<b>{{ a', $body->align('<b>{{ a')); + } +} + +class BodyProbeTag extends Tags +{ + public static array $seen = []; + + public function inspect() + { + static::$seen['content'] = $this->content; + + $parsed = $this->parse(); + + static::$seen['string'] = (string) $parsed; + + return $parsed; + } + + public function trimmed() + { + $this->content = trim($this->content); + + static::$seen['content'] = $this->content; + + return $this->parseLoop([['n' => 1], ['n' => 2]]); + } + + public function wrapped() + { + $this->content = '<i>'.$this->content.'</i>'; + + return $this->parse(); + } + + public function composed() + { + return '<section>'.$this->parse().'</section>'; + } + + public function transformed() + { + return strtoupper((string) $this->parse()); + } + + public function throws() + { + throw new \RuntimeException('body failed'); + } + + public function freshObject() + { + return Antlers::parse('<u>{{ title }}</u>', $this->context->all(), true); + } + + public function freshString() + { + return (string) Antlers::parse('<u>{{ title }}</u>', $this->context->all(), true); + } +} + +class BodyComposerTag extends Tags implements ComposesRenderedOutput +{ + public static array $seen = []; + + public function index() + { + static::$seen['content'] = $this->content; + + return '<section>'.$this->parse().'</section>'; + } +}