diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 44fbe5c51c4..86ed9d0c1f4 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -52,6 +52,7 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; +use Statamic\Tags\Structure as StructureTag; use Statamic\View\Cascade; class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Contract, Localization, Protectable, ResolvesValuesContract, Responsable, SearchableContract @@ -442,6 +443,7 @@ public function save() Blink::store('structure-entries')->forget($this->id()); Blink::forget($this->getOriginBlinkKey()); Blink::store('collection-mounts')->flush(); + StructureTag::forgetTrees(); } if ($origin = $this->origin()) { diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 768cf89d890..4ba1df3d947 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Foundation\Http\Middleware\TrimStrings; use Illuminate\Http\Request; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Session; @@ -30,6 +31,7 @@ use Statamic\Sites\Sites; use Statamic\Stache\Query\RevisionQueryBuilder; use Statamic\Statamic; +use Statamic\Tags\Structure as StructureTag; use Statamic\Tokens\Handlers\LivePreview; use Statamic\View\Scaffolding\TemplateGenerator; @@ -56,6 +58,12 @@ public function boot() Event::listen(\Laravel\Octane\Events\RequestReceived::class, fn () => Ffmpeg::clearBinaryCache()); } + Event::listen(JobProcessing::class, function () { + if (Statamic::isWorker()) { + StructureTag::forgetTrees(); + } + }); + $this->app[\Illuminate\Contracts\Http\Kernel::class] ->pushMiddleware(\Statamic\Http\Middleware\PoweredByHeader::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckComposerJsonScripts::class) diff --git a/src/Structures/Page.php b/src/Structures/Page.php index 6b372b0eba0..d434f84caa4 100644 --- a/src/Structures/Page.php +++ b/src/Structures/Page.php @@ -377,6 +377,11 @@ public function value(string $key) return $value; } + public function hasChildren(): bool + { + return ! empty($this->children); + } + public function pages() { $pages = (new Pages) diff --git a/src/Tags/Structure.php b/src/Tags/Structure.php index 448cb61e3a9..de106f73b49 100644 --- a/src/Tags/Structure.php +++ b/src/Tags/Structure.php @@ -6,6 +6,7 @@ use Statamic\Data\BulkAugmentor; use Statamic\Exceptions\CollectionNotFoundException; use Statamic\Exceptions\NavigationNotFoundException; +use Statamic\Facades\Blink; use Statamic\Facades\Collection; use Statamic\Facades\Nav; use Statamic\Facades\Site; @@ -51,14 +52,7 @@ protected function structure($handle) $this->ensureStructureExists($handle); - $tree = (new TreeBuilder)->build([ - 'structure' => $handle, - 'query' => $this->query($handle), - 'include_home' => $this->params->get('include_home'), - 'site' => $this->params->get('site', Site::current()->handle()), - 'from' => $this->params->get('from'), - 'max_depth' => $this->params->get('max_depth'), - ]); + $tree = $this->tree($handle); if ($tree) { $offset = max(0, $this->params->int('offset')); @@ -76,6 +70,112 @@ protected function structure($handle) return $value; } + protected function reusesTrees(): bool + { + return in_array(static::class, [self::class, \Statamic\Tags\Nav::class, Children::class]); + } + + /** + * A page often outputs the same nav more than once (a desktop menu and a + * mobile one, say), so a tree that has been built is kept for the rest of + * the request. Parameters that only change how much of the tree is shown + * aren't part of what identifies it: a tree built to a greater depth, or + * one that reached the bottom, serves a shallower tag too. + */ + private function tree(string $handle) + { + $maxDepth = (int) $this->params->get('max_depth') ?: null; + + if (! $this->reusesTrees() || $maxDepth < 0 || ! $key = $this->treeCacheKey($handle)) { + return $this->buildTree($handle, $maxDepth); + } + + $trees = Blink::store('structure-tag-trees'); + + $built = collect($trees->get($key))->first(function ($built) use ($maxDepth) { + return $built['complete'] || ($maxDepth && $built['max_depth'] >= $maxDepth); + }); + + if ($built) { + return $this->limitDepth($built['tree'], $maxDepth); + } + + $tree = $this->buildTree($handle, $maxDepth); + + $trees->put($key, [...$trees->get($key, []), [ + 'tree' => $tree, + 'max_depth' => $maxDepth, + 'complete' => ! $maxDepth || ! $this->wasCutShort($tree, $maxDepth), + ]]); + + return $tree; + } + + /** + * Dropped when an entry is saved, since the pages that have been kept carry + * its data. A worker has no requests to mark where one page ends and the + * next begins, so it drops them when it moves on to its next job. + */ + public static function forgetTrees(): void + { + Blink::store('structure-tag-trees')->flush(); + Blink::forget('structure-tag-nodes'); + } + + private function buildTree(string $handle, ?int $maxDepth) + { + return (new TreeBuilder)->build([ + 'structure' => $handle, + 'query' => $this->query($handle), + 'include_home' => $this->params->get('include_home'), + 'site' => $this->params->get('site', Site::current()->handle()), + 'from' => $this->params->get('from'), + 'max_depth' => $maxDepth, + ]); + } + + private function treeCacheKey(string $handle): ?string + { + $params = $this->params + ->except(['for', 'as', 'scope', 'max_depth', 'offset', 'limit', 'select', 'include_parents']) + ->all(); + + if (collect($params)->contains(fn ($param) => ! is_null($param) && ! is_scalar($param))) { + return null; + } + + ksort($params); + + // The tree's own contents are part of the key, so a tree that is saved + // partway through a request isn't served from before the change. + $site = $params['site'] ?? Site::current()->handle(); + $contents = \Statamic\Facades\Structure::find($handle)?->in($site)?->tree(); + + return md5(json_encode([static::class, $handle, $site, $params, $contents])); + } + + private function wasCutShort(?array $tree, int $maxDepth): bool + { + return collect($tree)->contains(function ($item) use ($maxDepth) { + return $item['depth'] >= $maxDepth + ? $item['page']->hasChildren() + : $this->wasCutShort($item['children'], $maxDepth); + }); + } + + private function limitDepth(?array $tree, ?int $maxDepth): ?array + { + if (! $tree || ! $maxDepth) { + return $tree; + } + + return collect($tree)->map(function ($item) use ($maxDepth) { + $item['children'] = $item['depth'] >= $maxDepth ? [] : $this->limitDepth($item['children'], $maxDepth); + + return $item; + })->all(); + } + protected function ensureStructureExists(string $handle): void { if (Str::startsWith($handle, 'collection::')) { @@ -130,12 +230,17 @@ protected function isQueryingStatus() public function toArray($tree, $parent = null, $depth = 1) { - $pages = BulkAugmentor::tree($tree, $this->params->explode('select'))->map(function ($item, $data, $index) use ($depth, $tree, $parent) { + if (! $tree) { + return []; + } + + $nodes = $this->nodes($tree); + + $pages = collect($tree)->map(function ($item, $index) use ($depth, $tree, $parent, $nodes) { $page = $item['page']; $children = empty($item['children']) ? [] : $this->toArray($item['children'], $page, $depth + 1); - $url = $page->urlWithoutRedirect(); - $absoluteUrl = $page->absoluteUrl(); + ['data' => $data, 'url' => $url, 'absolute_url' => $absoluteUrl, 'is_external' => $isExternal] = $nodes[$index]; return array_merge($data, [ 'children' => $children, @@ -146,7 +251,7 @@ public function toArray($tree, $parent = null, $depth = 1) 'last' => $index === count($tree) - 1, 'is_current' => ! is_null($url) && $url === $this->currentUrl, 'is_parent' => $this->isParent($url, $absoluteUrl), - 'is_external' => URL::isExternal((string) $absoluteUrl), + 'is_external' => $isExternal, ], $this->params->bool('include_parents', true) ? ['parent' => $parent] : []); })->filter()->values(); @@ -155,6 +260,35 @@ public function toArray($tree, $parent = null, $depth = 1) return $pages->all(); } + /** + * What a page contributes to the output wherever and however often it is + * shown: its augmented values and its URLs. Kept per page object for the + * rest of the request, so a tree that is reused isn't augmented again. + */ + private function nodes(array $tree): array + { + $select = $this->params->explode('select'); + $selected = json_encode($select); + $known = Blink::once('structure-tag-nodes', fn () => new \WeakMap); + + // Reindexed, because the augmentor numbers what it's given from zero. + $missing = collect($tree)->reject(fn ($item) => isset($known[$item['page']][$selected]))->values()->all(); + + foreach (BulkAugmentor::tree($missing, $select)->toArray() as $index => $data) { + $page = $missing[$index]['page']; + $absoluteUrl = $page->absoluteUrl(); + + $known[$page] = [...($known[$page] ?? []), $selected => [ + 'data' => $data, + 'url' => $page->urlWithoutRedirect(), + 'absolute_url' => $absoluteUrl, + 'is_external' => URL::isExternal((string) $absoluteUrl), + ]]; + } + + return collect($tree)->map(fn ($item) => $known[$item['page']][$selected])->all(); + } + private function isParent(?string $url, ?string $absoluteUrl): bool { if (is_null($url)) { diff --git a/tests/Tags/StructureTagTest.php b/tests/Tags/StructureTagTest.php index 2604d2454dc..35d5352840d 100644 --- a/tests/Tags/StructureTagTest.php +++ b/tests/Tags/StructureTagTest.php @@ -3,9 +3,11 @@ namespace Tests\Tags; use Facades\Tests\Factories\EntryFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Entries\QueryBuilder; use Statamic\Facades\Antlers; +use Statamic\Facades\Blink; use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Nav; @@ -666,6 +668,156 @@ public function it_doesnt_render_anything_when_nav_from_is_invalid() ], true)); } + #[Test] + #[DataProvider('repeatedNavProvider')] + public function a_nav_output_more_than_once_gives_what_each_tag_gives_on_its_own($first, $second) + { + $this->createCollectionAndNav(); + + $template = fn ($params) => '{{ nav:test '.$params.' }}[{{ id ?? entry_id }} {{ depth }}{{ if first }} first{{ /if }}{{ if last }} last{{ /if }}{{ if children }} > {{ children }}({{ id ?? entry_id }} {{ depth }}{{ if parent }} in {{ parent:id }}{{ /if }}){{ /children }}{{ /if }}]{{ /nav:test }}'; + + $alone = []; + foreach ([$first, $second] as $params) { + Blink::flush(); + Blink::store('structure-tag-trees')->flush(); + $alone[] = (string) Antlers::parse($template($params), [], true); + } + + Blink::flush(); + Blink::store('structure-tag-trees')->flush(); + + $this->assertNotSame('', $alone[0].$alone[1]); + $this->assertSame( + $alone[0].'|'.$alone[1].'|'.$alone[0], + (string) Antlers::parse($template($first).'|'.$template($second).'|'.$template($first), [], true) + ); + } + + public static function repeatedNavProvider() + { + return [ + 'the same tag twice' => ['', ''], + 'whole tree then one level' => ['', 'max_depth="1"'], + 'one level then whole tree' => ['max_depth="1"', ''], + 'one level then two' => ['max_depth="1"', 'max_depth="2"'], + 'two levels then one' => ['max_depth="2"', 'max_depth="1"'], + 'deeper than the tree, then all of it' => ['max_depth="5"', ''], + 'limited then offset' => ['limit="2"', 'offset="2"'], + 'limited then whole tree' => ['limit="2"', ''], + 'offset then whole tree' => ['offset="2"', ''], + 'with and without parents' => ['include_parents="false"', ''], + 'selected fields then all fields' => ['select="title"', ''], + 'hidden then shown unpublished entries' => ['', 'show_unpublished="true"'], + 'shown then hidden unpublished entries' => ['show_unpublished="true"', 'max_depth="1"'], + 'all fields then a selected star' => ['', 'select="*"'], + 'a selected star then all fields' => ['select="*"', ''], + 'whole tree then a negative depth' => ['', 'max_depth="-1"'], + 'a negative depth then whole tree' => ['max_depth="-1"', ''], + ]; + } + + #[Test] + public function a_nav_output_more_than_once_is_only_built_as_often_as_it_needs_to_be() + { + $this->createCollectionAndNav(); + + $built = fn () => collect(Blink::store('structure-tag-trees')->all())->flatten(1)->count(); + + // The whole tree serves any depth. + $this->parseBasicTemplate('test'); + $this->parseBasicTemplate('test', 'max_depth="1"'); + $this->parseBasicTemplate('test', 'limit="1" include_parents="false"'); + $this->assertSame(1, $built()); + + // One level of a deeper tree can't serve two levels, but two levels reach the bottom of this one. + Blink::store('structure-tag-trees')->flush(); + $this->parseBasicTemplate('test', 'max_depth="1"'); + $this->parseBasicTemplate('test', 'max_depth="2"'); + $this->parseBasicTemplate('test'); + $this->assertSame(2, $built()); + + // Parameters that change which pages are in the tree get a tree of their own. + $this->parseBasicTemplate('test', 'show_unpublished="true"'); + $this->assertSame(3, $built()); + } + + #[Test] + public function a_reused_nav_still_works_out_the_current_page_for_each_tag() + { + $template = '{{ nav:test }}[{{ id }}{{ if is_parent }}=parent{{ /if }}{{ if is_current }}=current{{ /if }}]{{ if children }}{{ *recursive children* }}{{ /if }}{{ /nav:test }}'; + + $mock = \Mockery::mock(\Statamic\Facades\URL::getFacadeRoot())->makePartial(); + \Statamic\Facades\URL::swap($mock); + + $this->makeNav([ + ['id' => '1', 'title' => '1', 'url' => '/1', 'children' => [ + ['id' => '1-1', 'title' => '1.1', 'url' => '/1/1'], + ]], + ['id' => '2', 'title' => '2', 'url' => '/2'], + ]); + + $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1'); + $this->assertEquals('[1=parent][1-1=current][2]', (string) Antlers::parse($template, [], true)); + + $mock->shouldReceive('getCurrent')->once()->andReturn('/2'); + $this->assertEquals('[1][1-1][2=current]', (string) Antlers::parse($template, [], true)); + + $this->assertCount(1, collect(Blink::store('structure-tag-trees')->all())->flatten(1)); + } + + #[Test] + public function a_nav_saved_after_it_was_output_is_built_again() + { + $this->makeNav([['id' => '1', 'title' => '1', 'url' => '/1']]); + $this->assertEquals('[1]', $this->parseBasicTemplate('test')); + + $this->makeNav([['id' => '1', 'title' => '1', 'url' => '/1'], ['id' => '2', 'title' => '2', 'url' => '/2']]); + $this->assertEquals('[1][2]', $this->parseBasicTemplate('test')); + } + + #[Test] + public function a_queue_worker_doesnt_keep_a_tree_from_one_job_to_the_next() + { + tap(Collection::make('pages')->routes('{slug}'))->save(); + $entry = EntryFactory::collection('pages')->id('one')->slug('one')->data(['title' => 'Old'])->create(); + $this->makeNav([['id' => 'a', 'entry' => 'one']]); + + $template = '{{ nav:test }}[{{ title }}]{{ /nav:test }}'; + + $this->assertEquals('[Old]', (string) Antlers::parse($template, [], true)); + + // Changed without saving, the way another process's change would look. + $entry->set('title', 'New'); + + $job = \Mockery::mock(\Illuminate\Contracts\Queue\Job::class); + $job->shouldReceive('payload')->andReturn([]); + // A job run synchronously during a request leaves what the request has kept alone. + event(new \Illuminate\Queue\Events\JobProcessing('sync', $job)); + $this->assertEquals('[Old]', (string) Antlers::parse($template, [], true)); + + $request = request(); + \Illuminate\Support\Facades\Request::swap(new \Tests\Fakes\FakeArtisanRequest('queue:work')); + event(new \Illuminate\Queue\Events\JobProcessing('redis', $job)); + \Illuminate\Support\Facades\Request::swap($request); + $this->assertEquals('[New]', (string) Antlers::parse($template, [], true)); + } + + #[Test] + public function an_entry_saved_after_the_nav_was_output_is_picked_up() + { + tap(Collection::make('pages')->routes('{slug}'))->save(); + $entry = EntryFactory::collection('pages')->id('one')->slug('one')->data(['title' => 'Old'])->create(); + $this->makeNav([['id' => 'a', 'entry' => 'one']]); + + $template = '{{ nav:test }}[{{ title }} {{ url }}]{{ /nav:test }}'; + + $this->assertEquals('[Old /one]', (string) Antlers::parse($template, [], true)); + + $entry->set('title', 'New')->slug('two')->save(); + + $this->assertEquals('[New /two]', (string) Antlers::parse($template, [], true)); + } + private function makeNav($tree) { $nav = Nav::make('test'); @@ -675,6 +827,37 @@ private function makeNav($tree) $nav->save(); } + #[Test] + public function a_subclass_builds_its_own_tree_each_time() + { + ContextualNavTag::register(); + + $this->makeNav([ + ['id' => 'a', 'title' => 'A', 'url' => '/a'], + ['id' => 'b', 'title' => 'B', 'url' => '/b'], + ]); + + $template = '{{ contextual_nav:test }}[{{ title }}]{{ /contextual_nav:test }}'; + + $this->assertEquals('[A]', (string) Antlers::parse($template, ['wanted' => 'A'], true)); + $this->assertEquals('[B]', (string) Antlers::parse($template, ['wanted' => 'B'], true)); + $this->assertCount(0, Blink::store('structure-tag-trees')->all()); + } + + #[Test] + public function a_subclass_that_reuses_trees_keeps_them() + { + ReusingNavTag::register(); + + $this->makeNav([['id' => 'a', 'title' => 'A', 'url' => '/a']]); + + $template = '{{ reusing_nav:test }}[{{ title }}]{{ /reusing_nav:test }}'; + + $this->assertEquals('[A]', (string) Antlers::parse($template, [], true)); + $this->assertEquals('[A]', (string) Antlers::parse($template, [], true)); + $this->assertCount(1, Blink::store('structure-tag-trees')->all()); + } + private function parseBasicTemplate($handle, $params = null) { return (string) Antlers::parse($this->createBasicTemplate($handle, $params), [], true); @@ -743,3 +926,23 @@ private function createCollectionAndNav() ]); } } + +class ContextualNavTag extends \Statamic\Tags\Nav +{ + protected static $handle = 'contextual_nav'; + + protected function queryForNav($query) + { + return parent::queryForNav($query)->where('title', $this->context->get('wanted')); + } +} + +class ReusingNavTag extends \Statamic\Tags\Nav +{ + protected static $handle = 'reusing_nav'; + + protected function reusesTrees(): bool + { + return true; + } +}