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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Changed

- **Faster builds, with less memory.** Statamic builds each entry's URL by running its route through Antlers with a new parser every time. Routes that are only placeholders (`/books/{slug}`, `{parent_uri}/{slug}`) now have their values put in directly; anything else still goes through Antlers. On the demo's 9,000-entry nested page builder site the build went from 21.5 s to 15.5 s and its memory from 356 MB to 285 MB.
- **Entries are cheaper to read.** Statamic snapshots an entry's state every time one is read, so it can later say what changed. Flatpack now works that snapshot out only when something asks for it. Reading 9,000 entries through a query went from 81 to 32 microseconds each.

## 1.1.1 (2026-11-10)

### Fixed
Expand Down
85 changes: 85 additions & 0 deletions src/FlatpackEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@
use NineteenNinetyFour\Flatpack\Lazy\LazyBody;
use NineteenNinetyFour\Flatpack\Lazy\LazyData;
use Statamic\Entries\Entry;
use Statamic\Facades\Blink;

/**
* Statamic's Entry with lazy heavy fields. Two core paths read every value and would load all
* bodies during indexing; both are routed around the placeholders here:
* - routeData(): a route template only sees the lazy fields it names
* - dirty-state snapshots keep placeholders instead of loading bodies
* The dirty-state snapshot is also only worked out when something asks for it.
*/
class FlatpackEntry extends Entry
{
/** This entry as it was when last read or saved, until its dirty-state snapshot is needed. */
private ?self $asRead = null;

public function data($data = null)
{
if (func_num_args() === 0) {
Expand Down Expand Up @@ -44,6 +49,86 @@ public function getCurrentDirtyStateAttributes(): array
return $this->withSwappedData(fn ($d) => collect($d->rawItems()), fn () => parent::getCurrentDirtyStateAttributes());
}

/**
* Statamic snapshots an entry's state every time one is read from the Stache, so it can
* later say what changed. Most entries are never asked. Keeping a copy of the entry as it
* was, and working the snapshot out from that copy only when something asks, costs a
* shallow clone per read instead.
*/
public function syncOriginal(): static
{
$this->asRead = null;
$asRead = clone $this;

// Older Statamic versions don't give a cloned entry its own data.
if ($asRead->data === $this->data) {
$asRead->data = clone $this->data;
}

// Dates can be changed in place, so the copy needs its own.
if ($asRead->date instanceof \DateTimeInterface) {
$asRead->date = clone $asRead->date;
}

$this->asRead = $asRead;

return $this;
}

public function getOriginal($key = null, $fallback = null)
{
$this->resolveOriginal();

return parent::getOriginal($key, $fallback);
}

public function isDirty($attributes = null): bool
{
$this->resolveOriginal();

return parent::isDirty($attributes);
}

private function resolveOriginal(): void
{
if (! $asRead = $this->asRead) {
return;
}

$this->asRead = null;

if (! $asRead->origin) {
$this->original = $asRead->getCurrentDirtyStateAttributes();

return;
}

// Statamic remembers an entry's origin by the entry's id, so the copy would be given
// this entry's origin as it is now. The copy has to see the origin it had when it was
// read, and what's remembered has to be put back afterwards.
$key = $asRead->getOriginBlinkKey();
$remembered = Blink::get($key);

if ($origin = $asRead->getOriginByString($asRead->origin)) {
Blink::put($key, $origin);
} else {
Blink::forget($key);
}

try {
$this->original = $asRead->getCurrentDirtyStateAttributes();
} finally {
$remembered ? Blink::put($key, $remembered) : Blink::forget($key);
}
}

public function __sleep()
{
$this->resolveOriginal();

return array_values(array_diff(parent::__sleep(), ['asRead']));
}

/** Run $fn with this entry's (and its origins') unloaded LazyData temporarily replaced. */
private function withSwappedData(callable $swap, callable $fn)
{
Expand Down
116 changes: 116 additions & 0 deletions src/Routing/FastUrlBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace NineteenNinetyFour\Flatpack\Routing;

use Statamic\Facades\Antlers;
use Statamic\Routing\UrlBuilder;
use Statamic\View\Antlers\Language\Runtime\EnvironmentDetails;

/**
* Statamic builds every entry's URL by running its route through Antlers, with a new parser
* each time. Most routes are only placeholders ("/books/{slug}", "{parent_uri}/{slug}"). For
* those, the values are put in here and the result goes through the rest of Statamic's
* builder (slugifying, tidying) with a parser that has nothing to do.
*
* Statamic's builder is used as it is for anything else: a route written in Antlers, a
* placeholder Antlers might read as something other than a variable, a placeholder with no
* value, and a value that isn't a plain string or integer or has braces in it.
*/
class FastUrlBuilder extends UrlBuilder
{
/** Names Antlers gives a meaning of its own. */
private const RESERVED = [
'true' => 1, 'false' => 1, 'null' => 1, 'void' => 1, 'if' => 1, 'else' => 1, 'elseif' => 1, 'unless' => 1, 'elseunless' => 1,
'endif' => 1, 'endunless' => 1, 'and' => 1, 'or' => 1, 'xor' => 1, 'not' => 1, 'switch' => 1, 'once' => 1, 'endonce' => 1,
'noparse' => 1, 'endnoparse' => 1, 'stop' => 1, 'recursive' => 1, 'new' => 1, 'bwa' => 1, 'bwo' => 1, 'bxor' => 1, 'bnot' => 1, 'bsl' => 1, 'bsr' => 1,
'list' => 1, 'as' => 1, 'arr' => 1, 'merge' => 1, 'pluck' => 1, 'where' => 1, 'take' => 1, 'skip' => 1, 'orderby' => 1, 'groupby' => 1,
];

private static ?PlainTextParser $plain = null;

private static ?object $noData = null;

public function build($route)
{
$pattern = is_array($route) ? ($route[$this->content->locale()] ?? null) : $route;

if (! is_string($pattern) || str_contains($pattern, '{{')) {
return parent::build($route);
}

$data = null;
$simple = true;

// The same placeholders Statamic turns into Antlers variables.
$filled = preg_replace_callback('/{\s*([a-zA-Z0-9_\-]+)\s*}/', function ($match) use (&$data, &$simple) {
$name = $match[1];

if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) || isset(self::RESERVED[strtolower($name)])) {
$simple = false;

return '';
}

$data ??= array_merge($this->content->routeData(), $this->merged ?? []);
$value = $data[$name] ?? null;

if (is_int($value)) {
return (string) $value;
}

if (is_string($value) && strpbrk($value, '{}') === false) {
return $value;
}

$simple = false;

return '';
}, $pattern);

if (! $simple || $filled === null || strpbrk($filled, '{}') !== false) {
return parent::build($route);
}

// Creating an Antlers parser also records which tags and modifiers exist at that moment,
// and a tag registered after the view engine was created is only seen once that happens
// again. Statamic's builder did it for every URL, so it is still done here.
app(EnvironmentDetails::class);

// Statamic's builder does the rest. It has no values left to look up, so it is given
// content with no route data rather than working the entry's out a second time.
$content = $this->content;
$merged = $this->merged ?? [];
$thrown = null;

$this->content = self::$noData ??= new class
{
public function routeData()
{
return [];
}
};
$this->merged = [];

try {
// usingParser() doesn't put the usual parser back if the callback throws, so nothing is thrown through it.
$url = Antlers::usingParser(self::$plain ??= new PlainTextParser, function () use ($filled, &$thrown) {
try {
return parent::build($filled);
} catch (\Throwable $e) {
$thrown = $e;

return null;
}
});
} finally {
$this->content = $content;
$this->merged = $merged;
}

if ($thrown) {
throw $thrown;
}

return $url;
}
}
57 changes: 57 additions & 0 deletions src/Routing/PlainTextParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace NineteenNinetyFour\Flatpack\Routing;

use Statamic\Contracts\View\Antlers\Parser;

/**
* Stands in for Antlers while a URL that has already had its values put in goes through
* Statamic's URL builder: the text has nothing left to parse, so it is returned as it is.
*/
final class PlainTextParser implements Parser
{
public function parse($text, $data = [])
{
return (string) $text;
}

public function parseView($view, $text, $data = [])
{
return (string) $text;
}

public function extractNoparse($text)
{
return $text;
}

public function injectNoparse($text)
{
return $text;
}

public function valueWithNoparse($text)
{
return $text;
}

public function getVariable($key, $context, $default = null)
{
return $default;
}

public function allowPhp($allow = true)
{
return $this;
}

public function callback($callback)
{
return $this;
}

public function cascade($cascade)
{
return $this;
}
}
1 change: 1 addition & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public function register(): void
$this->app->booting(function ($app) {
$app->bind(\Statamic\Contracts\Entries\Entry::class, FlatpackEntry::class);
$app->bind(\Statamic\Stache\Traverser::class, Stache\FastTraverser::class);
$app->bind(\Statamic\Contracts\Routing\UrlBuilder::class, Routing\FastUrlBuilder::class);
// Statamic parses with its own fast parser where it has one; older versions get Flatpack's.
$app->singleton(\Statamic\Yaml\Yaml::class, fn () => new LeanYaml(
Flatpack::statamicHasFastParser() ? new \Symfony\Component\Yaml\Yaml : new Yaml\SymfonyFast
Expand Down
71 changes: 71 additions & 0 deletions tests/parity/dirty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php
// Parity for change tracking: what an entry says its original state was, and whether it's
// dirty, after it has been read and then changed in memory. Nothing is saved.
// php dirty.php <appdir> <out.json>

use Statamic\Facades\Entry;

[$_, $appDir, $out] = $argv;
require "$appDir/vendor/autoload.php";
$app = require "$appDir/bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

$state = fn ($e, array $keys) => [
'dirty' => $e->isDirty(),
'dirty_keys' => array_values(array_filter($keys, fn ($k) => $e->isDirty($k))),
'original' => array_map(fn ($k) => $e->getOriginal($k), array_combine($keys, $keys)),
];
$keys = ['origin', 'slug', 'date', 'published', 'title', 'locale', 'collection'];

// Each case starts from a freshly read entry. Statamic remembers origins per request, and the
// unsaved changes made here would otherwise be remembered into the next case.
$fresh = function ($id) {
Statamic\Facades\Blink::flush();

return Entry::find($id);
};
$result = [];

$translations = Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()->filter(fn ($e) => $e->hasOrigin())->sortBy->id()->values();
$posts = Entry::query()->where('collection', 'blog')->where('site', 'en')->get()->sortBy->id()->values();

foreach ($translations->take(3) as $i => $translation) {
$id = $translation->id();
$other = $posts->first(fn ($e) => $e->id() !== $translation->origin()->id());

$e = $fresh($id);
$result["$i untouched"] = $state($e, $keys);

$e = $fresh($id);
$e->origin($other);
$result["$i origin changed"] = $state($e, $keys) + ['origin_now' => $e->origin()->id()];

$e = $fresh($id);
$e->origin(null);
$result["$i origin removed"] = $state($e, $keys) + ['origin_now' => $e->origin()?->id()];

$e = $fresh($id);
$e->set('title', 'Changed in memory')->slug('changed-in-memory')->published(! $e->published());
$result["$i data, slug and published changed"] = $state($e, $keys);
}

foreach ($posts->take(3) as $i => $post) {
$e = $fresh($post->id());
$e->date()?->addDays(3);
$result["post $i date moved in place"] = $state($e, $keys);

$e = $fresh($post->id());
$e->data()->put('title', 'Put straight into the data');
$result["post $i data changed through the collection"] = $state($e, $keys);

$e = unserialize(serialize($fresh($post->id())));
$e->set('title', 'After a round trip');
$result["post $i serialised and back"] = $state($e, $keys);

$e = clone $fresh($post->id());
$e->slug('cloned');
$result["post $i cloned"] = $state($e, $keys);
}

file_put_contents($out, json_encode($result, JSON_PRETTY_PRINT));
echo count($result), " dirty-state answers\n";
16 changes: 16 additions & 0 deletions tests/parity/generate.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@
}
}

// ---- notes and guides: routes that aren't only plain placeholders, so URLs still come from Antlers
$write("$c/collections/notes.yaml", $yaml(['title' => 'Notes', 'sites' => ['en'], 'route' => '/notes/{{ slug }}-{{ title | length }}']));
$write("$c/collections/guides.yaml", $yaml(['title' => 'Guides', 'sites' => ['en'], 'route' => '/guides/{topics}/{level}/{slug}']));
for ($i = 0; $i < 6; $i++) {
$slug = strtolower(str_replace(' ', '-', $title(2))).'-'.$i;
$write("$c/collections/notes/en/$slug.md", "---\n".$yaml(['id' => $uuid("note-$i"), 'title' => $title(3)])."---\n");
// topics is a list and level is sometimes missing or has braces in it: none of those can be put in directly
$guide = ['id' => $uuid("guide-$i"), 'title' => $title(3), 'topics' => ['alpha', 'beta']];
if ($i % 3 === 1) {
$guide['level'] = 'intro';
} elseif ($i % 3 === 2) {
$guide['level'] = 'a{b}c';
}
$write("$c/collections/guides/en/$slug.md", "---\n".$yaml($guide)."---\n");
}

// ---- team: single site, structured without root, orderable
$write("$c/collections/team.yaml", $yaml(['title' => 'Team', 'sites' => ['en'], 'route' => '/team/{slug}', 'structure' => ['max_depth' => 1]]));
$team = [];
Expand Down
Loading
Loading