Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
8 changes: 8 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/Structures/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
158 changes: 146 additions & 12 deletions src/Tags/Structure.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
Expand All @@ -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::')) {
Expand Down Expand Up @@ -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,
Expand All @@ -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();

Expand All @@ -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)) {
Expand Down
Loading
Loading