Skip to content
Open
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
132 changes: 132 additions & 0 deletions src/Listeners/CleanupSiteContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace Statamic\Listeners;

use Statamic\Events\SiteDeleted;
use Statamic\Events\Subscriber;
use Statamic\Facades\Blink;
use Statamic\Facades\Collection;
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Nav;
use Statamic\Facades\Taxonomy;
use Statamic\Taxonomies\LocalizedTerm;

class CleanupSiteContent extends Subscriber
{
protected $listeners = [
SiteDeleted::class => 'handle',
];

public function handle(SiteDeleted $event)
{
$site = $event->site->handle();

$this->cleanupCollections($site);
$this->cleanupTaxonomies($site);
$this->cleanupNavigations($site);
$this->cleanupGlobals($site);
}

/**
* Delete a collection's entries and tree for the site, then drop the site
* from the collection's config so it stops being referenced.
*/
private function cleanupCollections(string $site)
{
Collection::all()
->filter(fn ($collection) => $collection->sites()->contains($site))
->each(function ($collection) use ($site) {
$collection->queryEntries()->where('site', $site)->get()->each(function ($entry) {
// Re-root any localizations in surviving sites before removing their origin.
$entry->detachLocalizations();
$entry->delete();
});

if ($collection->hasStructure()) {
$collection->structure()->in($site)?->delete();
}

$remaining = $collection->sites()
->reject(fn ($handle) => $handle === $site)
->values()
->all();

$collection->sites($remaining)->save();
});
}

/**
* Drop the site from each taxonomy's config, then strip that site's data
* from every term (deleting terms that are left with no localizations).
*/
private function cleanupTaxonomies(string $site)
{
Taxonomy::all()
->filter(fn ($taxonomy) => $taxonomy->sites()->contains($site))
->each(function ($taxonomy) use ($site) {
$remaining = $taxonomy->sites()
->reject(fn ($handle) => $handle === $site)
->values()
->all();

$taxonomy->sites($remaining)->save();

// The term's default locale is derived from the taxonomy's sites,
// so make sure a stale copy isn't used when re-saving terms below.
Blink::forget("taxonomy-{$taxonomy->handle()}");

$sites = $taxonomy->sites();

$taxonomy->queryTerms()->get()->each(function ($term) use ($site, $sites) {
// Term queries yield localized terms; operate on the base term.
$term = $term instanceof LocalizedTerm ? $term->term() : $term;

$term->removeLocalization($site);

$surviving = $sites->filter(fn ($handle) => $term->hasLocalization($handle));

if ($surviving->isEmpty()) {
$term->delete();

return;
}

// The term is written with the taxonomy's first site as its root
// locale. If the deleted site was that root, promote a surviving
// localization so the term still has valid root data.
if (! $term->hasLocalization($default = $sites->first())) {
$term->dataForLocale($default, $term->dataForLocale($surviving->first())->all());
}

$term->save();
});
});
}

/**
* Delete each navigation's tree for the site. A nav's available sites are
* derived from its trees, so nothing else needs to change.
*/
private function cleanupNavigations(string $site)
{
Nav::all()->each(fn ($nav) => $nav->in($site)?->delete());
}

/**
* Drop the site from each global set's config, null out any origins that
* pointed at it, and let the set delete the orphaned localization on save.
*/
private function cleanupGlobals(string $site)
{
GlobalSet::all()
->filter(fn ($set) => $set->sites()->contains($site))
->each(function ($set) use ($site) {
$origins = $set->origins()
->reject(fn ($origin, $handle) => $handle === $site)
->map(fn ($origin) => $origin === $site ? null : $origin)
->all();

$set->sites($origins)->save();
});
}
}
1 change: 1 addition & 0 deletions src/Providers/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class EventServiceProvider extends ServiceProvider
\Statamic\Listeners\UpdateAssetReferences::class,
\Statamic\Listeners\UpdateTermReferences::class,
\Statamic\Listeners\InvalidateNavCache::class,
\Statamic\Listeners\CleanupSiteContent::class,
];

public function register()
Expand Down
12 changes: 12 additions & 0 deletions src/Taxonomies/Term.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ public function localizations()
});
}

public function hasLocalization($site)
{
return $this->data->has($site);
}

public function removeLocalization($site)
{
$this->data->forget($site);

return $this;
}

public function collection($collection = null)
{
return $this->fluentlyGetOrSet('collection')->args(func_get_args());
Expand Down
144 changes: 144 additions & 0 deletions tests/Listeners/CleanupSiteContentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

namespace Tests\Listeners;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\Structures\CollectionTreeRepository;
use Statamic\Facades\Collection;
use Statamic\Facades\Config;
use Statamic\Facades\Entry;
use Statamic\Facades\File;
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Nav;
use Statamic\Facades\Site;
use Statamic\Facades\Taxonomy;
use Statamic\Facades\Term;
use Statamic\Facades\YAML;
use Statamic\Sites\Sites;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class CleanupSiteContentTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

public function setUp(): void
{
parent::setUp();

File::put(resource_path('sites.yaml'), YAML::dump([
'en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US'],
'fr' => ['name' => 'French', 'url' => '/fr/', 'locale' => 'fr_FR'],
'de' => ['name' => 'German', 'url' => '/de/', 'locale' => 'de_DE'],
]));

Site::swap(new Sites);
Config::set('statamic.system.multisite', true);
}

private function deleteSite(string $handle): void
{
Site::setSites(collect(Site::config())->forget($handle)->all())->save();
}

#[Test]
public function it_deletes_content_belonging_to_a_removed_site()
{
$blog = tap(Collection::make('blog')->sites(['en', 'de']))->save();
Entry::make()->id('en-1')->locale('en')->collection('blog')->slug('en-1')->data(['title' => 'EN 1'])->save();
Entry::make()->id('de-1')->locale('de')->collection('blog')->slug('de-1')->data(['title' => 'DE 1'])->save();

$pages = tap(Collection::make('pages')->sites(['en', 'de'])->structureContents(['root' => true]))->save();
Entry::make()->id('p-en')->locale('en')->collection('pages')->slug('p-en')->data(['title' => 'P EN'])->save();
Entry::make()->id('p-de')->locale('de')->collection('pages')->slug('p-de')->data(['title' => 'P DE'])->save();
$pages->structure()->in('en')->tree([['entry' => 'p-en']])->save();
$pages->structure()->in('de')->tree([['entry' => 'p-de']])->save();

$nav = tap(Nav::make('main'))->save();
$nav->makeTree('en', [['id' => 'a']])->save();
$nav->makeTree('de', [['id' => 'b']])->save();

$tags = tap(Taxonomy::make('tags')->sites(['en', 'de']))->save();
$alpha = Term::make()->taxonomy('tags')->slug('alpha');
$alpha->dataForLocale('en', ['title' => 'Alpha EN']);
$alpha->dataForLocale('de', ['title' => 'Alpha DE']);
$alpha->save();

$company = tap(GlobalSet::make('company')->sites(['en' => null, 'de' => 'en']))->save();
$company->in('en')->data(['name' => 'ACME'])->save();
$company->in('de')->data(['name' => 'ACME DE'])->save();

$this->deleteSite('de');

$this->assertEquals(0, Entry::query()->where('site', 'de')->count());
$this->assertNotNull(Entry::find('en-1'));
$this->assertNotNull(Entry::find('p-en'));

$this->assertEquals(['en'], Collection::findByHandle('blog')->sites()->all());
$this->assertEquals(['en'], Collection::findByHandle('pages')->sites()->all());
$this->assertNull(Collection::findByHandle('pages')->structure()->in('de'));
$this->assertNull(app(CollectionTreeRepository::class)->find('pages', 'de'));

$this->assertNull(Nav::findByHandle('main')->in('de'));
$this->assertNotNull(Nav::findByHandle('main')->in('en'));

$this->assertEquals(['en'], Taxonomy::findByHandle('tags')->sites()->all());
$alpha = Term::find('tags::alpha')?->term();
$this->assertNotNull($alpha);
$this->assertTrue($alpha->dataForLocale('de')->isEmpty());
$this->assertEquals('Alpha EN', $alpha->dataForLocale('en')->get('title'));

$company = GlobalSet::findByHandle('company');
$this->assertNull($company->in('de'));
$this->assertFalse($company->sites()->contains('de'));
}

#[Test]
public function it_flattens_localizations_whose_origin_was_in_the_removed_site()
{
tap(Collection::make('blog')->sites(['en', 'de']))->save();

Entry::make()->id('origin')->locale('de')->collection('blog')->slug('origin')
->data(['title' => 'Origin DE', 'body' => 'shared body'])->save();
Entry::make()->id('loc')->locale('en')->collection('blog')->slug('loc')->origin('origin')
->data(['title' => 'Localized EN'])->save();

$this->deleteSite('de');

$this->assertNull(Entry::find('origin'));

$loc = Entry::find('loc');
$this->assertNotNull($loc);
$this->assertNull($loc->origin());
$this->assertEquals('Localized EN', $loc->value('title'));
$this->assertEquals('shared body', $loc->value('body'));
}

#[Test]
public function it_can_delete_the_default_site()
{
tap(Collection::make('blog')->sites(['en', 'fr', 'de']))->save();
Entry::make()->id('fr-1')->locale('fr')->collection('blog')->slug('fr-1')->data(['title' => 'FR 1'])->save();
Entry::make()->id('en-1')->locale('en')->collection('blog')->slug('en-1')->data(['title' => 'EN 1'])->save();

$tags = tap(Taxonomy::make('tags')->sites(['en', 'fr', 'de']))->save();
$alpha = Term::make()->taxonomy('tags')->slug('alpha');
$alpha->dataForLocale('en', ['title' => 'Alpha EN']);
$alpha->dataForLocale('de', ['title' => 'Alpha DE']);
$alpha->save();

$this->deleteSite('en');

$this->assertEquals('fr', Site::default()->handle());
$this->assertEquals(0, Entry::query()->where('site', 'en')->count());
$this->assertNotNull(Entry::find('fr-1'));
$this->assertEquals(['fr', 'de'], Collection::findByHandle('blog')->sites()->all());

// The term's root locale (en) was removed, so a surviving localization is
// promoted to the new default locale to keep the term file valid.
$alpha = Term::find('tags::alpha')?->term();
$this->assertNotNull($alpha);
$this->assertEquals('Alpha DE', $alpha->dataForLocale('de')->get('title'));
$this->assertEquals('Alpha DE', $alpha->dataForLocale('fr')->get('title'));
}
}
Loading