diff --git a/lang/en/messages.php b/lang/en/messages.php
index 6b27e97a..5ce87a6d 100644
--- a/lang/en/messages.php
+++ b/lang/en/messages.php
@@ -13,6 +13,7 @@
'section_defaults' => 'Section Defaults',
'section_defaults_description' => 'Override default meta, social, sitemap, and other settings for any particular section.',
'configure_site_defaults' => 'Configure Site Defaults',
+ 'configure_section_defaults' => 'Configure Section Defaults',
'meta' => 'Meta',
'opengraph' => 'Open Graph',
'social' => 'Social',
diff --git a/resources/js/components/section-defaults/ConfigureModal.vue b/resources/js/components/section-defaults/ConfigureModal.vue
new file mode 100644
index 00000000..9af9118d
--- /dev/null
+++ b/resources/js/components/section-defaults/ConfigureModal.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{ __('Site') }}
+
+ |
+
+
+ {{ __('Origin') }}
+
+ |
+
+
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/site-defaults/ConfigureModal.vue b/resources/js/components/site-defaults/ConfigureModal.vue
index 63255388..133cc9fe 100644
--- a/resources/js/components/site-defaults/ConfigureModal.vue
+++ b/resources/js/components/site-defaults/ConfigureModal.vue
@@ -23,7 +23,7 @@ const save = () => {
busy.value = true;
error.value = null;
- axios.patch(props.route, { sites: sites.value })
+ $axios.patch(props.route, { sites: sites.value })
.then(() => {
Statamic.$toast.success(__('Saved'));
emit('saved');
diff --git a/resources/js/cp.js b/resources/js/cp.js
index 6af63af6..b6990dd2 100644
--- a/resources/js/cp.js
+++ b/resources/js/cp.js
@@ -3,6 +3,7 @@ import ReportsIndex from './pages/reports/Index.vue';
import ReportsEmpty from './pages/reports/Empty.vue';
import ReportsShow from './pages/reports/Show.vue';
import SectionDefaultsIndex from './pages/section-defaults/Index.vue';
+import SectionDefaultsEdit from './pages/section-defaults/Edit.vue';
import SiteDefaultsEdit from './pages/site-defaults/Edit.vue';
import SeoProFieldtype from './components/fieldtypes/SeoProFieldtype.vue';
import PreviewsFieldtype from "./components/fieldtypes/PreviewsFieldtype.vue";
@@ -15,6 +16,7 @@ Statamic.booting(() => {
Statamic.$inertia.register('seo-pro::Reports/Empty', ReportsEmpty);
Statamic.$inertia.register('seo-pro::Reports/Show', ReportsShow);
Statamic.$inertia.register('seo-pro::SectionDefaults/Index', SectionDefaultsIndex);
+ Statamic.$inertia.register('seo-pro::SectionDefaults/Edit', SectionDefaultsEdit);
Statamic.$inertia.register('seo-pro::SiteDefaults/Edit', SiteDefaultsEdit);
Statamic.$components.register('seo_pro-fieldtype', SeoProFieldtype);
diff --git a/resources/js/pages/section-defaults/Edit.vue b/resources/js/pages/section-defaults/Edit.vue
new file mode 100644
index 00000000..3d391b0d
--- /dev/null
+++ b/resources/js/pages/section-defaults/Edit.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
router.reload()"
+ @closed="configureModalOpen = false"
+ />
+
+
+
+
diff --git a/routes/cp.php b/routes/cp.php
index 673d24f2..efb5ea27 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -18,7 +18,10 @@
Route::patch('site-defaults/configure', [Controllers\CP\ConfigureSiteDefaultsController::class, 'update'])->name('site-defaults.configure.update');
Route::get('section-defaults', [Controllers\CP\SectionDefaultsController::class, 'index'])->name('section-defaults.index');
+ Route::get('section-defaults/configure', [Controllers\CP\ConfigureSectionDefaultsController::class, 'edit'])->name('section-defaults.configure.edit');
+ Route::patch('section-defaults/configure', [Controllers\CP\ConfigureSectionDefaultsController::class, 'update'])->name('section-defaults.configure.update');
Route::get('section-defaults/collections/{seo_pro_collection}/edit', [Controllers\CP\CollectionDefaultsController::class, 'edit'])->name('section-defaults.collections.edit');
+
Route::patch('section-defaults/collections/{seo_pro_collection}', [Controllers\CP\CollectionDefaultsController::class, 'update'])->name('section-defaults.collections.update');
Route::get('section-defaults/taxonomies/{seo_pro_taxonomy}/edit', [Controllers\CP\TaxonomyDefaultsController::class, 'edit'])->name('section-defaults.taxonomies.edit');
Route::patch('section-defaults/taxonomies/{seo_pro_taxonomy}', [Controllers\CP\TaxonomyDefaultsController::class, 'update'])->name('section-defaults.taxonomies.update');
diff --git a/src/Events/SectionDefaultsSaved.php b/src/Events/SectionDefaultsSaved.php
new file mode 100644
index 00000000..0d1c1cf3
--- /dev/null
+++ b/src/Events/SectionDefaultsSaved.php
@@ -0,0 +1,11 @@
+getCacheKey($parent), function () use ($parent) {
- return $parent->cascade('seo');
- });
+ $type = $this->getSectionType($parent);
+ $handle = $parent->handle();
+ $locale = method_exists($current, 'locale') ? $current->locale() : null;
+
+ if (! $locale) {
+ return [];
+ }
+
+ $localized = SectionDefaults::in($type, $handle, $locale);
+
+ if (! $localized || ! $localized->isEnabled()) {
+ return false;
+ }
+
+ return Blink::once($this->getCacheKey($type, $handle, $locale), fn () => $localized->values()->all());
}
public function getAugmentedSectionDefaults($current)
@@ -27,26 +41,45 @@ public function getAugmentedSectionDefaults($current)
return [];
}
- return Blink::once($this->getCacheKey($parent).'.augmented', function () use ($parent) {
- return Blueprint::make()
- ->setContents([
- 'tabs' => [
- 'main' => [
- 'sections' => Fields::new()->getConfig(),
- ],
+ $type = $this->getSectionType($parent);
+ $handle = $parent->handle();
+ $locale = method_exists($current, 'locale') ? $current->locale() : null;
+
+ if (! $locale) {
+ return [];
+ }
+
+ $localized = SectionDefaults::in($type, $handle, $locale);
+
+ if (! $localized || ! $localized->isEnabled()) {
+ return [];
+ }
+
+ $seo = $localized->values()->all();
+
+ if (empty($seo)) {
+ return [];
+ }
+
+ return Blink::once($this->getCacheKey($type, $handle, $locale).'.augmented', fn () => Blueprint::make()
+ ->setContents([
+ 'tabs' => [
+ 'main' => [
+ 'sections' => Fields::new()->getConfig(),
],
- ])
- ->fields()
- ->addValues($seo = $parent->cascade('seo') ?: [])
- ->augment()
- ->values()
- ->only(array_keys($seo));
- });
+ ],
+ ])
+ ->fields()
+ ->addValues($seo)
+ ->augment()
+ ->values()
+ ->only(array_keys($seo))
+ );
}
- protected function getCacheKey($parent)
+ protected function getCacheKey(string $type, string $handle, string $locale): string
{
- return 'seo-pro.section-defaults.'.get_class($parent).'::'.$parent->handle();
+ return "seo-pro.section-defaults.{$type}::{$handle}::{$locale}";
}
protected function getSectionParent($current)
@@ -59,4 +92,11 @@ protected function getSectionParent($current)
return null;
}
+
+ protected function getSectionType($parent): string
+ {
+ return $parent instanceof Collection
+ ? 'collections'
+ : 'taxonomies';
+ }
}
diff --git a/src/Http/Controllers/CP/BaseSectionDefaultsController.php b/src/Http/Controllers/CP/BaseSectionDefaultsController.php
index d229b919..e6e9149a 100644
--- a/src/Http/Controllers/CP/BaseSectionDefaultsController.php
+++ b/src/Http/Controllers/CP/BaseSectionDefaultsController.php
@@ -3,12 +3,15 @@
namespace Statamic\SeoPro\Http\Controllers\CP;
use Illuminate\Http\Request;
+use Inertia\Inertia;
use Statamic\Contracts\Entries\Collection;
use Statamic\Contracts\Taxonomies\Taxonomy;
-use Statamic\CP\PublishForm;
use Statamic\Facades\Blueprint;
+use Statamic\Facades\Site;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\SeoPro\Fields;
+use Statamic\SeoPro\SectionDefaults\LocalizedSectionDefaults;
+use Statamic\SeoPro\SectionDefaults\SectionDefaults;
use Statamic\Support\Arr;
abstract class BaseSectionDefaultsController extends CpController
@@ -17,38 +20,93 @@ abstract class BaseSectionDefaultsController extends CpController
abstract protected function getSectionItem($handle);
- public function edit($handle)
+ public function edit(Request $request, $handle)
{
$this->authorize('edit seo section defaults');
$sectionType = static::$sectionType;
+ $site = $request->site ?? Site::selected()->handle();
$item = $this->getSectionItem($handle);
- $seo = Arr::get($item->fileData(), 'inject.seo', []);
+ $localized = SectionDefaults::in($sectionType, $handle, $site);
- if ($seo === false) {
- $seo = ['enabled' => false];
+ $blueprint = $this->blueprint();
+
+ [$values, $meta] = $this->extractFromFields($localized, $blueprint);
+
+ if ($hasOrigin = $localized->hasOrigin()) {
+ [$originValues, $originMeta] = $this->extractFromFields($localized->origin(), $blueprint);
+ }
+
+ $viewData = [
+ 'blueprint' => $blueprint->toPublishArray(),
+ 'initialReference' => $localized->reference(),
+ 'initialValues' => $values,
+ 'initialMeta' => $meta,
+ 'initialEnabled' => $localized->isEnabled(),
+ 'initialLocalizations' => SectionDefaults::get($sectionType, $handle)->map(function (LocalizedSectionDefaults $loc) use ($localized, $sectionType, $handle): array {
+ return [
+ 'handle' => $loc->locale(),
+ 'name' => $loc->site()->name(),
+ 'active' => $loc->locale() === $localized->locale(),
+ 'origin' => ! $loc->hasOrigin(),
+ 'url' => cp_route("seo-pro.section-defaults.{$sectionType}.edit", array_filter([
+ $handle,
+ 'site' => Site::multiEnabled() ? $loc->locale() : null,
+ ])),
+ ];
+ })->values()->all(),
+ 'initialLocalizedFields' => $localized->defaults()->keys()->all(),
+ 'initialHasOrigin' => $hasOrigin,
+ 'initialOriginValues' => $originValues ?? null,
+ 'initialOriginMeta' => $originMeta ?? null,
+ 'initialSite' => $site,
+ 'action' => cp_route("seo-pro.section-defaults.{$sectionType}.update", $handle),
+ 'title' => $item->title().' SEO',
+ 'configureUrl' => Site::multiEnabled()
+ ? cp_route('seo-pro.section-defaults.configure.edit')
+ : null,
+ ];
+
+ if ($request->wantsJson()) {
+ return $viewData;
}
- return PublishForm::make($this->blueprint())
- ->asConfig()
- ->icon('folder')
- ->title($item->title().' SEO')
- ->values($seo)
- ->submittingTo(cp_route("seo-pro.section-defaults.{$sectionType}.update", $item));
+ return Inertia::render('seo-pro::SectionDefaults/Edit', $viewData);
}
public function update($handle, Request $request)
{
$this->authorize('edit seo section defaults');
- $values = PublishForm::make($this->blueprint())->submit($request->all());
+ $sectionType = static::$sectionType;
+ $site = $request->site ?? Site::selected()->handle();
+
+ $localized = SectionDefaults::in($sectionType, $handle, $site);
+ $blueprint = $this->blueprint();
- $this->saveSectionItem(
- item: $this->getSectionItem($handle),
- values: Arr::removeNullValues($values)
+ $fields = $blueprint->fields()->addValues($request->all());
+ $fields->validate();
+ $values = collect($fields->process()->values());
+
+ if ($localized->hasOrigin()) {
+ $values = $values->only($request->input('_localized'));
+ }
+
+ $save = $this->saveSectionDefaults(
+ type: $sectionType,
+ handle: $handle,
+ site: $site,
+ values: Arr::removeNullValues($values->all()),
+ localized: $localized,
);
+
+ if ($values->get('enabled') === false) {
+ $this->removeChildSeo($this->getSectionItem($handle));
+ }
+
+ return ['saved' => $save];
}
protected function blueprint()
@@ -62,25 +120,41 @@ protected function blueprint()
]);
}
- protected function saveSectionItem($item, $values)
+ protected function saveSectionDefaults(string $type, string $handle, string $site, array $values, LocalizedSectionDefaults $localized): bool
{
$values = collect($values);
- $cascade = $item->cascade();
+ if ($values->get('enabled') === false) {
+ SectionDefaults::disable($type, $handle);
- if ($disabled = $values->get('enabled') === false) {
- $cascade->put('seo', false);
- } elseif ($values->except('enabled')->isEmpty()) {
- $cascade->forget('seo');
- } else {
- $cascade->put('seo', $values->except('enabled')->all());
+ return true;
}
- $item->cascade($cascade->all())->save();
+ $localized->set($values->except('enabled')->all());
+
+ return $localized->save();
+ }
+
+ private function extractFromFields(LocalizedSectionDefaults $defaults, \Statamic\Fields\Blueprint $blueprint): array
+ {
+ $values = collect();
+ $target = $defaults;
+
+ while ($target) {
+ $values = $target->defaults()->merge($values);
+ $target = $target->origin();
+ }
- if ($disabled) {
- $this->removeChildSeo($item);
+ if (! $defaults->isEnabled()) {
+ $values->put('enabled', false);
}
+
+ $fields = $blueprint
+ ->fields()
+ ->addValues($values->all())
+ ->preProcess();
+
+ return [$fields->values()->all(), $fields->meta()->all()];
}
protected function removeChildSeo($item)
diff --git a/src/Http/Controllers/CP/ConfigureSectionDefaultsController.php b/src/Http/Controllers/CP/ConfigureSectionDefaultsController.php
new file mode 100644
index 00000000..3a0779b3
--- /dev/null
+++ b/src/Http/Controllers/CP/ConfigureSectionDefaultsController.php
@@ -0,0 +1,55 @@
+authorize('edit seo section defaults');
+
+ throw_unless(Site::hasMultiple(), NotFoundHttpException::class);
+
+ return [
+ 'sites' => Site::all()->map(function ($site) {
+ return [
+ 'name' => $site->name(),
+ 'handle' => $site->handle(),
+ 'origin' => SectionDefaults::origins()->get($site->handle()),
+ ];
+ })->values(),
+ ];
+ }
+
+ public function update(Request $request)
+ {
+ $this->authorize('edit seo section defaults');
+
+ throw_unless(Site::hasMultiple(), NotFoundHttpException::class);
+
+ $request->validate([
+ 'sites' => ['required', 'array', 'min:1'],
+ 'sites.*.handle' => ['required', 'string'],
+ 'sites.*.origin' => ['nullable', 'string'],
+ ]);
+
+ if ($request->collect('sites')->map->origin->filter()->count() == count($request->sites)) {
+ throw ValidationException::withMessages([
+ 'sites' => __('statamic::validation.one_site_without_origin'),
+ ]);
+ }
+
+ $sites = $request->collect('sites')
+ ->mapWithKeys(fn ($site) => [$site['handle'] => $site['origin']])
+ ->all();
+
+ SectionDefaults::origins($sites);
+ }
+}
diff --git a/src/Http/Controllers/CP/SiteDefaultsController.php b/src/Http/Controllers/CP/SiteDefaultsController.php
index 258cdbf9..f4a2cd69 100755
--- a/src/Http/Controllers/CP/SiteDefaultsController.php
+++ b/src/Http/Controllers/CP/SiteDefaultsController.php
@@ -9,6 +9,7 @@
use Statamic\Http\Controllers\CP\CpController;
use Statamic\SeoPro\SiteDefaults\LocalizedSiteDefaults;
use Statamic\SeoPro\SiteDefaults\SiteDefaults;
+use Statamic\Support\Arr;
class SiteDefaultsController extends CpController
{
@@ -75,7 +76,7 @@ public function update(Request $request)
$values = $values->only($request->input('_localized'));
}
- $siteDefaults->set($values->filter()->all());
+ $siteDefaults->set(Arr::removeNullValues($values->all()));
$save = $siteDefaults->save();
diff --git a/src/Reporting/Chunk.php b/src/Reporting/Chunk.php
index 4d795858..a1fb7050 100644
--- a/src/Reporting/Chunk.php
+++ b/src/Reporting/Chunk.php
@@ -79,6 +79,10 @@ protected function createPage($content)
return;
}
+ if ($this->getSectionDefaults($content) === false) {
+ return;
+ }
+
$data = (new Cascade)
->withSiteDefaults(SiteDefaults::in($content->locale())->augmented())
->withSectionDefaults($this->getAugmentedSectionDefaults($content))
diff --git a/src/SectionDefaults/LocalizedSectionDefaults.php b/src/SectionDefaults/LocalizedSectionDefaults.php
new file mode 100644
index 00000000..354cc672
--- /dev/null
+++ b/src/SectionDefaults/LocalizedSectionDefaults.php
@@ -0,0 +1,149 @@
+type}::{$this->handle}::{$this->locale}";
+ }
+
+ public function reference(): string
+ {
+ return $this->id();
+ }
+
+ public function type(): string
+ {
+ return $this->type;
+ }
+
+ public function handle(): string
+ {
+ return $this->handle;
+ }
+
+ public function locale(): string
+ {
+ return $this->locale;
+ }
+
+ public function site(): \Statamic\Sites\Site
+ {
+ return Site::get($this->locale());
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function defaults(): Collection
+ {
+ return $this->defaults;
+ }
+
+ public function all(): array
+ {
+ return $this->defaults->all();
+ }
+
+ public function set(string|array $key, mixed $value = null): self
+ {
+ if (is_array($key)) {
+ $this->defaults = collect($key);
+
+ return $this;
+ }
+
+ $this->defaults->put($key, $value);
+
+ return $this;
+ }
+
+ public function save(): bool
+ {
+ $save = SectionDefaults::save($this);
+
+ SectionDefaultsSaved::dispatch($this);
+
+ return $save;
+ }
+
+ public function origin(): ?LocalizedSectionDefaults
+ {
+ $origin = SectionDefaults::origins()->get($this->locale);
+
+ if (! $origin) {
+ return null;
+ }
+
+ return SectionDefaults::in($this->type, $this->handle, $origin);
+ }
+
+ public function getOriginByString($origin): ?LocalizedSectionDefaults
+ {
+ return SectionDefaults::in($this->type, $this->handle, $origin);
+ }
+
+ public function __get(string $name)
+ {
+ // The HasOrigin trait accesses the "data" property, but we call that "defaults".
+ if ($name === 'data') {
+ return $this->defaults;
+ }
+ }
+
+ public function augmented(): array
+ {
+ $blueprint = Blueprint::make()
+ ->setContents(['tabs' => ['main' => ['sections' => Fields::new()->getConfig()]]]);
+
+ return $blueprint
+ ->fields()
+ ->addValues($this->values()->all())
+ ->augment()
+ ->values()
+ ->only($this->keys()->all())
+ ->all();
+ }
+
+ public function editUrl(): string
+ {
+ return $this->cpUrl('edit');
+ }
+
+ public function updateUrl(): string
+ {
+ return $this->cpUrl('update');
+ }
+
+ private function cpUrl(string $action): string
+ {
+ $params = [$this->handle];
+
+ if (Site::multiEnabled()) {
+ $params['site'] = $this->locale();
+ }
+
+ return cp_route("seo-pro.section-defaults.{$this->type}.{$action}", $params);
+ }
+}
diff --git a/src/SectionDefaults/SectionDefaults.php b/src/SectionDefaults/SectionDefaults.php
new file mode 100644
index 00000000..ee8d0e77
--- /dev/null
+++ b/src/SectionDefaults/SectionDefaults.php
@@ -0,0 +1,234 @@
+map(fn ($site) => new LocalizedSectionDefaults(
+ type: $type,
+ handle: $handle,
+ locale: $site->handle(),
+ defaults: collect(),
+ enabled: false,
+ ));
+ }
+
+ return Site::all()->map(function ($site) use ($type, $handle, $raw) {
+ $values = Site::multiEnabled()
+ ? Arr::get($raw, "sites.{$site->handle()}", [])
+ : ($raw ?? []);
+
+ return new LocalizedSectionDefaults(
+ type: $type,
+ handle: $handle,
+ locale: $site->handle(),
+ defaults: collect($values),
+ enabled: true,
+ );
+ });
+ });
+ }
+
+ /**
+ * Get LocalizedSectionDefaults for a specific locale.
+ */
+ public static function in(string $type, string $handle, string $locale): ?LocalizedSectionDefaults
+ {
+ return self::get($type, $handle)->get($locale);
+ }
+
+ /**
+ * Check if a section is globally enabled.
+ */
+ public static function isEnabled(string $type, string $handle): bool
+ {
+ return self::rawForHandle($type, $handle) !== false;
+ }
+
+ /**
+ * Disable a section globally (for all sites).
+ */
+ public static function disable(string $type, string $handle): void
+ {
+ $data = Arr::get(Addon::get('statamic/seo-pro')->settings()->raw(), 'section_defaults', []);
+
+ Arr::set($data, "{$type}.{$handle}", false);
+
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save();
+
+ self::clearCache($type, $handle);
+ self::clearInjectSeo($type, $handle);
+ }
+
+ /**
+ * Save a LocalizedSectionDefaults instance to addon settings.
+ */
+ public static function save(LocalizedSectionDefaults $localized): bool
+ {
+ $data = Arr::get(Addon::get('statamic/seo-pro')->settings()->raw(), 'section_defaults', []);
+ $type = $localized->type();
+ $handle = $localized->handle();
+ $locale = $localized->locale();
+ $values = $localized->all();
+
+ if (Site::multiEnabled()) {
+ // Clear any top-level false (disabled) value before saving site data.
+ if (Arr::get($data, "{$type}.{$handle}") === false) {
+ Arr::forget($data, "{$type}.{$handle}");
+ }
+
+ if (empty($values)) {
+ Arr::forget($data, "{$type}.{$handle}.sites.{$locale}");
+
+ // If no sites have values left, remove the entire handle entry.
+ $remainingSites = Arr::get($data, "{$type}.{$handle}.sites", []);
+ if (empty($remainingSites)) {
+ Arr::forget($data, "{$type}.{$handle}");
+ }
+ } else {
+ Arr::set($data, "{$type}.{$handle}.sites.{$locale}", $values);
+ }
+ } else {
+ if (empty($values)) {
+ Arr::forget($data, "{$type}.{$handle}");
+ } else {
+ Arr::set($data, "{$type}.{$handle}", $values);
+ }
+ }
+
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save();
+
+ self::clearCache($type, $handle);
+ self::clearInjectSeo($type, $handle);
+
+ return true;
+ }
+
+ /**
+ * Get or set the per-site origin map for section defaults.
+ */
+ public static function origins($origins = null): Collection|bool
+ {
+ if (func_num_args() === 0) {
+ return Site::all()
+ ->mapWithKeys(fn ($site) => [$site->handle() => null])
+ ->merge(Addon::get('statamic/seo-pro')->settings()->get('section_defaults_sites', []))
+ ->map(fn ($origin) => empty($origin) ? null : $origin);
+ }
+
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults_sites', $origins)->save();
+
+ return true;
+ }
+
+ private static function rawForHandle(string $type, string $handle): array|false
+ {
+ // Use settings()->raw() rather than settings()->get() because Statamic's Antlers
+ // resolver converts PHP false to an empty string, losing the disabled state.
+ $data = Arr::get(Addon::get('statamic/seo-pro')->settings()->raw(), 'section_defaults', []);
+
+ // If explicitly present in addon settings, use that value.
+ if (Arr::has($data, "{$type}.{$handle}")) {
+ $raw = Arr::get($data, "{$type}.{$handle}");
+
+ if ($raw === false) {
+ return false;
+ }
+
+ return is_array($raw) ? $raw : [];
+ }
+
+ // Fall back to inject.seo from the collection/taxonomy YAML (legacy storage).
+ return self::rawFromInjectSeo($type, $handle);
+ }
+
+ /**
+ * Read section defaults from the legacy inject.seo location in collection/taxonomy YAML.
+ */
+ private static function rawFromInjectSeo(string $type, string $handle): array|false
+ {
+ $item = self::findSectionItem($type, $handle);
+
+ if (! $item) {
+ return [];
+ }
+
+ $injectSeo = Arr::get($item->fileData(), 'inject.seo');
+
+ if ($injectSeo === null) {
+ return [];
+ }
+
+ if ($injectSeo === false) {
+ return false;
+ }
+
+ if (! is_array($injectSeo) || empty($injectSeo)) {
+ return [];
+ }
+
+ // inject.seo was always a flat set of values (no per-site support in the old design).
+ // In multi-site mode, treat these as the default site's values so origin inheritance
+ // can propagate them to child sites naturally.
+ if (Site::multiEnabled()) {
+ return ['sites' => [Site::default()->handle() => $injectSeo]];
+ }
+
+ return $injectSeo;
+ }
+
+ /**
+ * Strip inject.seo from the collection/taxonomy YAML, migrating it to addon settings.
+ * Called automatically after saving or disabling via the new API.
+ */
+ private static function clearInjectSeo(string $type, string $handle): void
+ {
+ $item = self::findSectionItem($type, $handle);
+
+ if (! $item) {
+ return;
+ }
+
+ if (! Arr::has($item->fileData(), 'inject.seo')) {
+ return;
+ }
+
+ $cascade = $item->cascade();
+ $cascade->forget('seo');
+ $item->cascade($cascade->all())->save();
+ }
+
+ private static function findSectionItem(string $type, string $handle)
+ {
+ return $type === 'collections'
+ ? CollectionFacade::find($handle)
+ : TaxonomyFacade::find($handle);
+ }
+
+ private static function cacheKey(string $type, string $handle): string
+ {
+ return "seo-pro::section-defaults::{$type}::{$handle}";
+ }
+
+ public static function clearCache(string $type, string $handle): void
+ {
+ Blink::forget(self::cacheKey($type, $handle));
+ }
+}
diff --git a/src/Sitemap/Sitemap.php b/src/Sitemap/Sitemap.php
index bc9b9c75..00f13ccb 100644
--- a/src/Sitemap/Sitemap.php
+++ b/src/Sitemap/Sitemap.php
@@ -16,6 +16,7 @@
use Statamic\Facades\Taxonomy;
use Statamic\SeoPro\Cascade;
use Statamic\SeoPro\GetsSectionDefaults;
+use Statamic\SeoPro\SectionDefaults\SectionDefaults;
use Statamic\SeoPro\SiteDefaults\SiteDefaults;
use Statamic\Sites\Site;
use Statamic\Support\Traits\Hookable;
@@ -156,7 +157,7 @@ protected function publishedEntriesQuery()
{
$collections = Collection::all()
->map(function ($collection) {
- return $collection->cascade('seo') !== false
+ return SectionDefaults::isEnabled('collections', $collection->handle())
? $collection->handle()
: false;
})
@@ -200,7 +201,7 @@ protected function publishedTerms()
{
return Taxonomy::all()
->flatMap(function ($taxonomy) {
- return $taxonomy->cascade('seo') !== false
+ return SectionDefaults::isEnabled('taxonomies', $taxonomy->handle())
? $taxonomy
->queryTerms()
->when($this->sites()->isNotEmpty(), fn (Builder $query) => $query->whereIn('site', $this->sites()->map->handle()->all()))
@@ -218,12 +219,12 @@ protected function publishedCollectionTerms()
{
return Collection::all()
->flatMap(function ($collection) {
- return $collection->cascade('seo') !== false
+ return SectionDefaults::isEnabled('collections', $collection->handle())
? $collection->taxonomies()->map->collection($collection)
: collect();
})
->flatMap(function ($taxonomy) {
- return $taxonomy->cascade('seo') !== false
+ return SectionDefaults::isEnabled('taxonomies', $taxonomy->handle())
? $taxonomy
->queryTerms()
->when($this->sites()->isNotEmpty(), fn (Builder $query) => $query->whereIn('site', $this->sites()->map->handle()->all()))
diff --git a/src/Subscriber.php b/src/Subscriber.php
index 7d2cfd40..42af2e91 100644
--- a/src/Subscriber.php
+++ b/src/Subscriber.php
@@ -4,8 +4,7 @@
use Illuminate\Events\Dispatcher;
use Statamic\Events;
-use Statamic\Facades\Collection;
-use Statamic\Facades\Taxonomy;
+use Statamic\SeoPro\SectionDefaults\SectionDefaults;
use Statamic\SeoPro\Sitemap\Sitemap;
use Statamic\Support\Str;
@@ -72,13 +71,11 @@ protected function seoIsEnabledForSection($event)
$namespace = $event->blueprint->namespace();
if (Str::startsWith($namespace, 'collections.')) {
- $section = Collection::findByHandle(Str::after($namespace, 'collections.'));
+ return SectionDefaults::isEnabled('collections', Str::after($namespace, 'collections.'));
} elseif (Str::startsWith($namespace, 'taxonomies.')) {
- $section = Taxonomy::findByHandle(Str::after($namespace, 'taxonomies.'));
+ return SectionDefaults::isEnabled('taxonomies', Str::after($namespace, 'taxonomies.'));
} else {
throw new \Exception('Unknown section type.');
}
-
- return $section->cascade('seo') !== false;
}
}
diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php
index 06dd8fe4..e5b558d6 100755
--- a/src/Tags/SeoProTags.php
+++ b/src/Tags/SeoProTags.php
@@ -2,6 +2,7 @@
namespace Statamic\SeoPro\Tags;
+use Statamic\Facades\Entry;
use Statamic\Facades\Image;
use Statamic\Facades\Site;
use Statamic\SeoPro\Cascade;
@@ -28,6 +29,16 @@ public function meta()
return;
}
+ // When the seo field is present in the blueprint, augmentable() returns the entry/term.
+ // When the section is disabled, the seo field is removed from the blueprint, so we
+ // fall back to looking up the entry by ID from the context.
+ $current = optional($this->context->get('seo'))->augmentable()
+ ?? Entry::find($this->context->raw('id'));
+
+ if ($current && $this->getSectionDefaults($current) === false) {
+ return;
+ }
+
return $this->renderMetaHtml($this->metaData(), true);
}
diff --git a/tests/Localized/ConfigureSectionDefaultsTest.php b/tests/Localized/ConfigureSectionDefaultsTest.php
new file mode 100644
index 00000000..a13dc6fd
--- /dev/null
+++ b/tests/Localized/ConfigureSectionDefaultsTest.php
@@ -0,0 +1,154 @@
+settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => null,
+ 'italian' => 'british',
+ 'british' => 'default',
+ ])->save();
+ }
+
+ #[Test]
+ public function can_edit_section_defaults_configuration()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->get('/cp/seo-pro/section-defaults/configure')
+ ->assertOk()
+ ->assertJson([
+ 'sites' => [
+ ['name' => 'English', 'handle' => 'default', 'origin' => null],
+ ['name' => 'French', 'handle' => 'french', 'origin' => null],
+ ['name' => 'Italian', 'handle' => 'italian', 'origin' => 'british'],
+ ['name' => 'British', 'handle' => 'british', 'origin' => 'default'],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function can_update_section_defaults_configuration()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patch('/cp/seo-pro/section-defaults/configure', [
+ 'sites' => [
+ ['name' => 'English', 'handle' => 'default', 'origin' => null],
+ ['name' => 'French', 'handle' => 'french', 'origin' => 'default'],
+ ['name' => 'Italian', 'handle' => 'italian', 'origin' => null],
+ ['name' => 'British', 'handle' => 'british', 'origin' => 'default'],
+ ],
+ ])
+ ->assertOk();
+
+ $this->assertEquals([
+ 'default' => null,
+ 'french' => 'default',
+ 'italian' => null,
+ 'british' => 'default',
+ ], Addon::get('statamic/seo-pro')->settings()->get('section_defaults_sites'));
+ }
+
+ #[Test]
+ public function can_update_section_defaults_configuration_when_every_site_has_an_origin()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patch('/cp/seo-pro/section-defaults/configure', [
+ 'sites' => [
+ ['name' => 'English', 'handle' => 'default', 'origin' => 'french'],
+ ['name' => 'French', 'handle' => 'french', 'origin' => 'default'],
+ ['name' => 'Italian', 'handle' => 'italian', 'origin' => 'british'],
+ ['name' => 'British', 'handle' => 'british', 'origin' => 'default'],
+ ],
+ ])
+ ->assertSessionHasErrors('sites');
+
+ // Original values unchanged.
+ $this->assertEquals([
+ 'default' => null,
+ 'french' => null,
+ 'italian' => 'british',
+ 'british' => 'default',
+ ], Addon::get('statamic/seo-pro')->settings()->get('section_defaults_sites'));
+ }
+
+ #[Test]
+ public function configure_uses_section_defaults_sites_not_site_defaults_sites()
+ {
+ // Saving section origins must not touch site_defaults_sites.
+ Addon::get('statamic/seo-pro')->settings()->set('site_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ ])->save();
+
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patch('/cp/seo-pro/section-defaults/configure', [
+ 'sites' => [
+ ['handle' => 'default', 'origin' => null],
+ ['handle' => 'french', 'origin' => null],
+ ['handle' => 'italian', 'origin' => null],
+ ['handle' => 'british', 'origin' => null],
+ ],
+ ])
+ ->assertOk();
+
+ // Site defaults origins must be untouched.
+ $this->assertEquals(
+ ['default' => null, 'french' => 'default'],
+ Addon::get('statamic/seo-pro')->settings()->get('site_defaults_sites')
+ );
+ }
+
+ #[Test]
+ public function cant_edit_or_update_without_permission()
+ {
+ Role::make('editor')->permissions(['access cp'])->save();
+
+ $this
+ ->actingAs(User::make()->assignRole('editor')->save())
+ ->get('/cp/seo-pro/section-defaults/configure')
+ ->assertRedirect('/cp');
+
+ $this
+ ->actingAs(User::make()->assignRole('editor')->save())
+ ->patch('/cp/seo-pro/section-defaults/configure', [
+ 'sites' => [['handle' => 'default', 'origin' => null]],
+ ])
+ ->assertRedirect('/cp');
+ }
+
+ #[Test]
+ public function cant_edit_or_update_unless_multisite_is_enabled()
+ {
+ Site::setSites([
+ 'default' => ['name' => 'English', 'locale' => 'en_US', 'url' => 'http://cool-runnings.com'],
+ ]);
+
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->get('/cp/seo-pro/section-defaults/configure')
+ ->assertNotFound();
+
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patch('/cp/seo-pro/section-defaults/configure', [
+ 'sites' => [['handle' => 'default', 'origin' => null]],
+ ])
+ ->assertNotFound();
+ }
+}
diff --git a/tests/Localized/SectionDefaultsControllerTest.php b/tests/Localized/SectionDefaultsControllerTest.php
new file mode 100644
index 00000000..26fd85fd
--- /dev/null
+++ b/tests/Localized/SectionDefaultsControllerTest.php
@@ -0,0 +1,229 @@
+settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ 'italian' => 'default',
+ 'british' => 'default',
+ ])->save();
+ }
+
+ // -------------------------------------------------------------------------
+ // Edit (GET) — multi-site
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function edit_multisite_returns_configure_url()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertStringContainsString('section-defaults/configure', $response->json('configureUrl'));
+ }
+
+ #[Test]
+ public function edit_defaults_to_selected_site()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertEquals('default', $response->json('initialSite'));
+ }
+
+ #[Test]
+ public function can_edit_section_defaults_for_a_specific_site()
+ {
+ SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French title'])->save();
+
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french');
+
+ $response->assertOk();
+ $this->assertEquals('french', $response->json('initialSite'));
+ // seo_pro_source preProcess transforms 'French title' to {source: 'custom', value: 'French title'}.
+ $this->assertEquals(['source' => 'custom', 'value' => 'French title'], $response->json('initialValues.title'));
+ }
+
+ #[Test]
+ public function edit_returns_all_sites_in_localizations()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $localizations = $response->json('initialLocalizations');
+
+ $this->assertCount(4, $localizations);
+ $handles = collect($localizations)->pluck('handle')->all();
+ $this->assertContains('default', $handles);
+ $this->assertContains('french', $handles);
+ $this->assertContains('italian', $handles);
+ $this->assertContains('british', $handles);
+ }
+
+ #[Test]
+ public function edit_marks_requested_site_as_active()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french');
+
+ $localizations = collect($response->json('initialLocalizations'));
+
+ $active = $localizations->firstWhere('active', true);
+ $this->assertEquals('french', $active['handle']);
+ }
+
+ #[Test]
+ public function edit_indicates_has_origin_for_child_site()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french');
+
+ $response->assertOk();
+ $this->assertTrue($response->json('initialHasOrigin'));
+ $this->assertNotNull($response->json('initialOriginValues'));
+ }
+
+ #[Test]
+ public function edit_indicates_no_origin_for_root_site()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=default');
+
+ $response->assertOk();
+ $this->assertFalse($response->json('initialHasOrigin'));
+ $this->assertNull($response->json('initialOriginValues'));
+ }
+
+ #[Test]
+ public function edit_origin_values_come_from_parent_site()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Origin title'])->save();
+
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french');
+
+ $response->assertOk();
+ // seo_pro_source preProcess transforms 'Origin title' to {source: 'custom', value: 'Origin title'}.
+ $this->assertEquals(['source' => 'custom', 'value' => 'Origin title'], $response->json('initialOriginValues.title'));
+ }
+
+ #[Test]
+ public function edit_shows_section_disabled_for_all_sites_when_disabled()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ foreach (['default', 'french', 'italian', 'british'] as $site) {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson("/cp/seo-pro/section-defaults/collections/pages/edit?site={$site}");
+
+ $response->assertOk();
+ $this->assertFalse($response->json('initialEnabled'), "Expected disabled for site {$site}");
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Update (PATCH) — multi-site
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function can_update_section_defaults_for_a_specific_site()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [
+ 'title' => ['source' => 'custom', 'value' => 'French SEO title'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $french = SectionDefaults::in('collections', 'pages', 'french');
+ $this->assertEquals('French SEO title', $french->all()['title']);
+ }
+
+ #[Test]
+ public function updating_one_site_does_not_overwrite_another()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English title'])->save();
+
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [
+ 'title' => ['source' => 'custom', 'value' => 'French title'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertEquals('English title', $raw['section_defaults']['collections']['pages']['sites']['default']['title']);
+ $this->assertEquals('French title', $raw['section_defaults']['collections']['pages']['sites']['french']['title']);
+ }
+
+ #[Test]
+ public function can_disable_collection_via_update_in_multisite()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [
+ 'enabled' => false,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ // Disabled state is global — all sites should report disabled.
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertFalse($raw['section_defaults']['collections']['pages']);
+ }
+
+ #[Test]
+ public function update_stores_values_under_sites_key_for_multi_site()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=default', [
+ 'title' => ['source' => 'field', 'value' => 'title'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertArrayHasKey('sites', $raw['section_defaults']['collections']['pages']);
+ $this->assertEquals('@seo:title', $raw['section_defaults']['collections']['pages']['sites']['default']['title']);
+ }
+}
diff --git a/tests/Localized/SectionDefaultsTest.php b/tests/Localized/SectionDefaultsTest.php
new file mode 100644
index 00000000..decda742
--- /dev/null
+++ b/tests/Localized/SectionDefaultsTest.php
@@ -0,0 +1,324 @@
+assertCount(4, $all);
+ $all->each(fn ($loc) => $this->assertInstanceOf(LocalizedSectionDefaults::class, $loc));
+ }
+
+ #[Test]
+ public function it_stores_values_under_sites_key_for_multi_site()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'En title'])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertEquals(
+ ['title' => 'En title'],
+ $raw['section_defaults']['collections']['pages']['sites']['default']
+ );
+ }
+
+ #[Test]
+ public function it_can_save_and_read_back_section_defaults_per_site()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save();
+ SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $this->assertEquals(['title' => 'English'], SectionDefaults::in('collections', 'pages', 'default')->all());
+ $this->assertEquals(['title' => 'French'], SectionDefaults::in('collections', 'pages', 'french')->all());
+ }
+
+ #[Test]
+ public function it_disables_section_globally_for_all_sites()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $all = SectionDefaults::get('collections', 'pages');
+
+ foreach ($all as $localized) {
+ $this->assertFalse($localized->isEnabled());
+ }
+
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_clears_disabled_state_when_saving_site_values()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ // Saving site values should re-enable the section.
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Re-enabled'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages'));
+
+ // In multi-site mode the values are stored under sites.{locale}.
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertEquals(
+ ['title' => 'Re-enabled'],
+ $raw['section_defaults']['collections']['pages']['sites']['default']
+ );
+ }
+
+ #[Test]
+ public function it_removes_site_entry_when_saving_empty_values()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save();
+ SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ // Clear the french values.
+ SectionDefaults::in('collections', 'pages', 'french')->set([])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $sites = $raw['section_defaults']['collections']['pages']['sites'] ?? [];
+
+ $this->assertArrayNotHasKey('french', $sites);
+ $this->assertArrayHasKey('default', $sites);
+ }
+
+ #[Test]
+ public function it_removes_handle_entry_when_all_sites_have_empty_values()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ // Clear the only remaining site.
+ SectionDefaults::in('collections', 'pages', 'default')->set([])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertArrayNotHasKey('pages', $raw['section_defaults']['collections'] ?? []);
+ }
+
+ #[Test]
+ public function it_resolves_origin_from_section_defaults_sites_config()
+ {
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ 'italian' => 'default',
+ 'british' => 'default',
+ ])->save();
+
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English origin'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $french = SectionDefaults::in('collections', 'pages', 'french');
+
+ $this->assertTrue($french->hasOrigin());
+
+ $origin = $french->origin();
+ $this->assertNotNull($origin);
+ $this->assertSame('default', $origin->locale());
+ $this->assertEquals(['title' => 'English origin'], $origin->all());
+ }
+
+ #[Test]
+ public function it_has_no_origin_when_section_defaults_sites_config_is_absent()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'french');
+
+ $this->assertFalse($localized->hasOrigin());
+ $this->assertNull($localized->origin());
+ }
+
+ #[Test]
+ public function it_inherits_values_from_origin_via_has_origin_trait()
+ {
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ 'italian' => 'default',
+ 'british' => 'default',
+ ])->save();
+
+ SectionDefaults::in('collections', 'pages', 'default')
+ ->set(['title' => 'Inherited title', 'description' => 'Inherited desc'])
+ ->save();
+
+ SectionDefaults::in('collections', 'pages', 'french')
+ ->set(['title' => 'French title'])
+ ->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $french = SectionDefaults::in('collections', 'pages', 'french');
+
+ // values() merges own + origin through HasOrigin trait.
+ $values = $french->values();
+
+ $this->assertEquals('French title', $values->get('title'));
+ $this->assertEquals('Inherited desc', $values->get('description'));
+ }
+
+ #[Test]
+ public function origins_returns_map_from_section_defaults_sites()
+ {
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ ])->save();
+
+ $origins = SectionDefaults::origins();
+
+ $this->assertNull($origins->get('default'));
+ $this->assertEquals('default', $origins->get('french'));
+ }
+
+ #[Test]
+ public function it_preserves_other_sites_when_saving_one_site()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save();
+ SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save();
+
+ // Update only default.
+ SectionDefaults::clearCache('collections', 'pages');
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English Updated'])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $sites = $raw['section_defaults']['collections']['pages']['sites'];
+
+ $this->assertEquals('English Updated', $sites['default']['title']);
+ $this->assertEquals('French', $sites['french']['title']);
+ }
+
+ #[Test]
+ public function it_preserves_disabled_state_across_all_sites()
+ {
+ // Guards against the Antlers resolver converting false to "" in multi-site.
+ SectionDefaults::disable('collections', 'pages');
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertFalse($raw['section_defaults']['collections']['pages']);
+
+ SectionDefaults::clearCache('collections', 'pages');
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ // -------------------------------------------------------------------------
+ // inject.seo fallback (legacy storage) — multi-site
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function it_falls_back_to_inject_seo_and_assigns_to_default_site()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy title']])
+ ->save();
+
+ $defaultHandle = Site::default()->handle();
+
+ $localized = SectionDefaults::in('collections', 'pages', $defaultHandle);
+
+ $this->assertTrue($localized->isEnabled());
+ $this->assertEquals(['title' => 'Legacy title'], $localized->all());
+ }
+
+ #[Test]
+ public function inject_seo_fallback_returns_empty_for_non_default_sites()
+ {
+ // inject.seo has no per-site support — only the default site gets values.
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy title']])
+ ->save();
+
+ $french = SectionDefaults::in('collections', 'pages', 'french');
+
+ // French has no own values from inject.seo fallback.
+ $this->assertEmpty($french->all());
+ }
+
+ #[Test]
+ public function inject_seo_fallback_values_are_inherited_by_child_sites_via_origin()
+ {
+ Addon::get('statamic/seo-pro')->settings()->set('section_defaults_sites', [
+ 'default' => null,
+ 'french' => 'default',
+ 'italian' => 'default',
+ 'british' => 'default',
+ ])->save();
+
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy title', 'description' => 'Desc']])
+ ->save();
+
+ $french = SectionDefaults::in('collections', 'pages', 'french');
+
+ // French has origin=default, so values() includes inherited values.
+ $inherited = $french->values();
+ $this->assertEquals('Legacy title', $inherited->get('title'));
+ $this->assertEquals('Desc', $inherited->get('description'));
+ }
+
+ #[Test]
+ public function it_reports_disabled_from_inject_seo_in_multisite()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => false])
+ ->save();
+
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $all = SectionDefaults::get('collections', 'pages');
+ foreach ($all as $localized) {
+ $this->assertFalse($localized->isEnabled());
+ }
+ }
+
+ #[Test]
+ public function saving_strips_inject_seo_from_yaml_in_multisite()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy']])
+ ->save();
+
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save();
+
+ $collection = Collection::find('pages');
+ $this->assertArrayNotHasKey('seo', $collection->cascade()->all());
+
+ // Value is now under addon settings sites key.
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertEquals(
+ ['title' => 'Migrated'],
+ $raw['section_defaults']['collections']['pages']['sites'][Site::default()->handle()]
+ );
+ }
+}
diff --git a/tests/SectionDefaultsControllerTest.php b/tests/SectionDefaultsControllerTest.php
new file mode 100644
index 00000000..b63e94a4
--- /dev/null
+++ b/tests/SectionDefaultsControllerTest.php
@@ -0,0 +1,269 @@
+actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $response->assertJsonStructure([
+ 'blueprint',
+ 'initialValues',
+ 'initialMeta',
+ 'initialEnabled',
+ 'initialLocalizations',
+ 'initialLocalizedFields',
+ 'initialHasOrigin',
+ 'initialSite',
+ 'action',
+ 'title',
+ 'configureUrl',
+ ]);
+ }
+
+ #[Test]
+ public function edit_collection_returns_correct_title()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertEquals('Pages SEO', $response->json('title'));
+ }
+
+ #[Test]
+ public function edit_single_site_returns_null_configure_url()
+ {
+ // Single-site fixture — no origin configuration needed.
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertNull($response->json('configureUrl'));
+ }
+
+ #[Test]
+ public function edit_collection_shows_section_as_enabled_by_default()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertTrue($response->json('initialEnabled'));
+ }
+
+ #[Test]
+ public function edit_collection_shows_section_as_disabled_when_disabled()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ $this->assertFalse($response->json('initialEnabled'));
+ }
+
+ #[Test]
+ public function edit_collection_includes_localizations_array()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $localizations = $response->json('initialLocalizations');
+ $this->assertCount(1, $localizations);
+ $this->assertEquals('default', $localizations[0]['handle']);
+ $this->assertTrue($localizations[0]['active']);
+ }
+
+ #[Test]
+ public function edit_collection_includes_saved_values()
+ {
+ SectionDefaults::in('collections', 'pages', 'default')
+ ->set(['title' => '@seo:title'])
+ ->save();
+
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit');
+
+ $response->assertOk();
+ // seo_pro_source fieldtype preProcess transforms '@seo:title' to {source: 'field', value: 'title'}.
+ $this->assertEquals(['source' => 'field', 'value' => 'title'], $response->json('initialValues.title'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Edit (GET) — taxonomies
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function can_edit_taxonomy_section_defaults()
+ {
+ $response = $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->getJson('/cp/seo-pro/section-defaults/taxonomies/topics/edit');
+
+ $response->assertOk();
+ $this->assertEquals('Topics SEO', $response->json('title'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Update (PATCH) — collections
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function can_update_collection_section_defaults()
+ {
+ // POST the pre-processed format that the Vue frontend sends.
+ // seo_pro_source fieldtype: {source: 'field', value: 'title'} → stored as '@seo:title'
+ // {source: 'custom', value: '...'} → stored as the raw string
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [
+ 'title' => ['source' => 'field', 'value' => 'title'],
+ 'description' => ['source' => 'custom', 'value' => 'My page description'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $this->assertTrue($localized->isEnabled());
+ $this->assertEquals('@seo:title', $localized->all()['title']);
+ $this->assertEquals('My page description', $localized->all()['description']);
+ }
+
+ #[Test]
+ public function can_disable_collection_via_update()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [
+ 'enabled' => false,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function disabling_a_collection_via_update_stores_false_in_settings()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [
+ 'enabled' => false,
+ ])
+ ->assertOk();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertFalse($raw['section_defaults']['collections']['pages']);
+ }
+
+ #[Test]
+ public function can_update_taxonomy_section_defaults()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/taxonomies/topics', [
+ 'title' => ['source' => 'field', 'value' => 'title'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('taxonomies', 'topics');
+
+ $localized = SectionDefaults::in('taxonomies', 'topics', 'default');
+ $this->assertEquals('@seo:title', $localized->all()['title']);
+ }
+
+ #[Test]
+ public function enabled_field_is_not_stored_in_section_defaults_values()
+ {
+ $this
+ ->actingAs(User::make()->makeSuper()->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [
+ 'title' => ['source' => 'field', 'value' => 'title'],
+ 'enabled' => true,
+ ])
+ ->assertOk();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $this->assertArrayNotHasKey('enabled', $localized->all());
+ }
+
+ // -------------------------------------------------------------------------
+ // Permissions
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function cannot_edit_collection_defaults_without_permission()
+ {
+ Role::make('editor')->permissions(['access cp'])->save();
+
+ $this
+ ->actingAs(User::make()->assignRole('editor')->save())
+ ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit')
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function cannot_update_collection_defaults_without_permission()
+ {
+ Role::make('editor')->permissions(['access cp'])->save();
+
+ $this
+ ->actingAs(User::make()->assignRole('editor')->save())
+ ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [
+ 'title' => '@seo:title',
+ 'enabled' => true,
+ ])
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function cannot_edit_taxonomy_defaults_without_permission()
+ {
+ Role::make('editor')->permissions(['access cp'])->save();
+
+ $this
+ ->actingAs(User::make()->assignRole('editor')->save())
+ ->getJson('/cp/seo-pro/section-defaults/taxonomies/topics/edit')
+ ->assertForbidden();
+ }
+}
diff --git a/tests/SectionDefaultsTest.php b/tests/SectionDefaultsTest.php
new file mode 100644
index 00000000..2a2166ba
--- /dev/null
+++ b/tests/SectionDefaultsTest.php
@@ -0,0 +1,396 @@
+assertCount(1, $all);
+
+ $localized = $all->first();
+ $this->assertInstanceOf(LocalizedSectionDefaults::class, $localized);
+ $this->assertSame('default', $localized->locale());
+ $this->assertTrue($localized->isEnabled());
+ $this->assertEmpty($localized->all());
+ }
+
+ #[Test]
+ public function it_returns_disabled_localized_defaults_when_section_is_disabled()
+ {
+ Addon::get('statamic/seo-pro')->settings()
+ ->set('section_defaults', ['collections' => ['pages' => false]])
+ ->save();
+
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $this->assertFalse($localized->isEnabled());
+ }
+
+ #[Test]
+ public function it_reports_section_as_enabled_when_no_settings_exist()
+ {
+ $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_reports_section_as_disabled_after_calling_disable()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_stores_false_in_addon_settings_when_disabling()
+ {
+ SectionDefaults::disable('collections', 'pages');
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertFalse($raw['section_defaults']['collections']['pages']);
+ }
+
+ #[Test]
+ public function it_can_save_section_defaults()
+ {
+ $locale = 'default';
+ $localized = SectionDefaults::in('collections', 'pages', $locale);
+ $localized->set(['title' => '@seo:title', 'description' => 'My page'])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertEquals(
+ ['title' => '@seo:title', 'description' => 'My page'],
+ $raw['section_defaults']['collections']['pages']
+ );
+ }
+
+ #[Test]
+ public function it_stores_values_flat_for_single_site()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $localized->set(['title' => '@seo:title'])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ // No 'sites' nesting in single-site mode.
+ $this->assertArrayNotHasKey('sites', $raw['section_defaults']['collections']['pages']);
+ $this->assertEquals('@seo:title', $raw['section_defaults']['collections']['pages']['title']);
+ }
+
+ #[Test]
+ public function it_can_read_saved_defaults_back_via_in()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $localized->set(['title' => 'Hello'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $fresh = SectionDefaults::in('collections', 'pages', 'default');
+
+ $this->assertEquals(['title' => 'Hello'], $fresh->all());
+ }
+
+ #[Test]
+ public function it_removes_handle_entry_when_saving_empty_values()
+ {
+ // Save some values first.
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Hello'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ // Now save empty values — the handle entry should be removed.
+ SectionDefaults::in('collections', 'pages', 'default')->set([])->save();
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertArrayNotHasKey('pages', $raw['section_defaults']['collections'] ?? []);
+ }
+
+ #[Test]
+ public function it_clears_blink_cache_on_save()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $localized->set(['title' => 'First'])->save();
+
+ SectionDefaults::clearCache('collections', 'pages');
+
+ // Warm the cache.
+ SectionDefaults::get('collections', 'pages');
+
+ // Save new values.
+ $localized->set(['title' => 'Second'])->save();
+
+ // Should not return stale cache.
+ $fresh = SectionDefaults::in('collections', 'pages', 'default');
+ $this->assertEquals('Second', $fresh->all()['title']);
+ }
+
+ #[Test]
+ public function it_re_enables_a_disabled_section_by_saving_values()
+ {
+ SectionDefaults::disable('collections', 'pages');
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Hello'])->save();
+
+ $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_can_get_section_defaults_for_a_taxonomy()
+ {
+ $localized = SectionDefaults::in('taxonomies', 'topics', 'default');
+ $localized->set(['title' => 'Topics title'])->save();
+
+ SectionDefaults::clearCache('taxonomies', 'topics');
+
+ $fresh = SectionDefaults::in('taxonomies', 'topics', 'default');
+ $this->assertEquals(['title' => 'Topics title'], $fresh->all());
+ }
+
+ #[Test]
+ public function it_can_disable_a_taxonomy_section()
+ {
+ SectionDefaults::disable('taxonomies', 'topics');
+
+ $this->assertFalse(SectionDefaults::isEnabled('taxonomies', 'topics'));
+ }
+
+ #[Test]
+ public function it_returns_null_origin_in_single_site_with_no_origins_configured()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $this->assertNull($localized->origin());
+ $this->assertFalse($localized->hasOrigin());
+ }
+
+ #[Test]
+ public function it_returns_values_via_set_method()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $localized->set('title', 'My Title');
+ $localized->set('description', 'My description');
+
+ $this->assertEquals('My Title', $localized->all()['title']);
+ $this->assertEquals('My description', $localized->all()['description']);
+ }
+
+ #[Test]
+ public function set_with_array_replaces_all_values()
+ {
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $localized->set(['title' => 'First', 'description' => 'Desc']);
+ $localized->set(['title' => 'Replaced']);
+
+ $this->assertEquals(['title' => 'Replaced'], $localized->all());
+ }
+
+ #[Test]
+ public function it_preserves_other_section_defaults_when_saving()
+ {
+ SectionDefaults::in('collections', 'articles', 'default')->set(['title' => 'Articles'])->save();
+ SectionDefaults::clearCache('collections', 'articles');
+
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Pages'])->save();
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+
+ $this->assertEquals('Articles', $raw['section_defaults']['collections']['articles']['title']);
+ $this->assertEquals('Pages', $raw['section_defaults']['collections']['pages']['title']);
+ }
+
+ #[Test]
+ public function it_preserves_disabled_state_when_reading_raw_settings()
+ {
+ // This test guards against the Antlers resolver converting false to "".
+ SectionDefaults::disable('collections', 'pages');
+
+ // Raw settings should have false, not "" or null.
+ $raw = Addon::get('statamic/seo-pro')->settings()->raw();
+ $this->assertFalse($raw['section_defaults']['collections']['pages']);
+
+ // isEnabled() must return false.
+ SectionDefaults::clearCache('collections', 'pages');
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_returns_collection_of_localized_defaults_for_each_site()
+ {
+ $all = SectionDefaults::get('collections', 'pages');
+
+ // Single-site fixture has one site.
+ $this->assertCount(1, $all);
+ $this->assertInstanceOf(LocalizedSectionDefaults::class, $all->first());
+ }
+
+ #[Test]
+ public function localized_defaults_is_enabled_returns_false_when_whole_section_is_disabled()
+ {
+ SectionDefaults::disable('collections', 'pages');
+ SectionDefaults::clearCache('collections', 'pages');
+
+ $all = SectionDefaults::get('collections', 'pages');
+
+ foreach ($all as $localized) {
+ $this->assertFalse($localized->isEnabled());
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // inject.seo fallback (legacy storage)
+ // -------------------------------------------------------------------------
+
+ #[Test]
+ public function it_falls_back_to_inject_seo_when_no_addon_settings_exist()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => '@seo:title', 'description' => 'Legacy desc']])
+ ->save();
+
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $this->assertTrue($localized->isEnabled());
+ $this->assertEquals(['title' => '@seo:title', 'description' => 'Legacy desc'], $localized->all());
+ }
+
+ #[Test]
+ public function it_reports_section_as_disabled_when_inject_seo_is_false()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => false])
+ ->save();
+
+ $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages'));
+ }
+
+ #[Test]
+ public function it_prefers_addon_settings_over_inject_seo()
+ {
+ // Set inject.seo on the YAML.
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'From YAML']])
+ ->save();
+
+ // Set a different value in addon settings.
+ Addon::get('statamic/seo-pro')->settings()
+ ->set('section_defaults', ['collections' => ['pages' => ['title' => 'From Settings']]])
+ ->save();
+
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+
+ $this->assertEquals('From Settings', $localized->all()['title']);
+ }
+
+ #[Test]
+ public function it_strips_inject_seo_from_yaml_when_saving_to_addon_settings()
+ {
+ // Set up legacy inject.seo.
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy']])
+ ->save();
+
+ // Saving via the new API should migrate and strip inject.seo.
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save();
+
+ $collection = Collection::find('pages');
+ $this->assertArrayNotHasKey('seo', $collection->cascade()->all());
+ }
+
+ #[Test]
+ public function it_strips_inject_seo_from_yaml_when_disabling()
+ {
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy']])
+ ->save();
+
+ SectionDefaults::disable('collections', 'pages');
+
+ $collection = Collection::find('pages');
+ $this->assertArrayNotHasKey('seo', $collection->cascade()->all());
+ }
+
+ #[Test]
+ public function after_lazy_migration_subsequent_reads_use_addon_settings()
+ {
+ // Set up legacy inject.seo.
+ Collection::find('pages')
+ ->cascade(['seo' => ['title' => 'Legacy']])
+ ->save();
+
+ // Trigger lazy migration by saving.
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save();
+
+ // Remove inject.seo from the collection manually to confirm it's gone.
+ $collection = Collection::find('pages');
+ $this->assertArrayNotHasKey('seo', $collection->cascade()->all());
+
+ // Subsequent read comes from addon settings.
+ SectionDefaults::clearCache('collections', 'pages');
+ $localized = SectionDefaults::in('collections', 'pages', 'default');
+ $this->assertEquals('Migrated', $localized->all()['title']);
+ }
+
+ #[Test]
+ public function it_falls_back_to_inject_seo_for_taxonomies()
+ {
+ Taxonomy::find('topics')
+ ->cascade(['seo' => ['title' => 'Topics Legacy']])
+ ->save();
+
+ $localized = SectionDefaults::in('taxonomies', 'topics', 'default');
+
+ $this->assertTrue($localized->isEnabled());
+ $this->assertEquals(['title' => 'Topics Legacy'], $localized->all());
+ }
+
+ #[Test]
+ public function it_strips_inject_seo_from_taxonomy_yaml_when_saving()
+ {
+ Taxonomy::find('topics')
+ ->cascade(['seo' => ['title' => 'Legacy']])
+ ->save();
+
+ SectionDefaults::in('taxonomies', 'topics', 'default')->set(['title' => 'Migrated'])->save();
+
+ $taxonomy = Taxonomy::find('topics');
+ $this->assertArrayNotHasKey('seo', $taxonomy->cascade()->all());
+ }
+
+ #[Test]
+ public function it_does_not_modify_yaml_when_inject_seo_is_absent()
+ {
+ // No inject.seo in the fixture — saving should not throw or modify YAML unexpectedly.
+ SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'New'])->save();
+
+ // Collection YAML should not have gained an inject.seo key.
+ $collection = Collection::find('pages');
+ $this->assertArrayNotHasKey('seo', $collection->cascade()->all());
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 6b7f7ab0..b0e770af 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -10,6 +10,7 @@
use Statamic\Facades\Site;
use Statamic\Facades\URL;
use Statamic\Facades\YAML;
+use Statamic\SeoPro\SectionDefaults\SectionDefaults;
use Statamic\SeoPro\ServiceProvider;
use Statamic\SeoPro\SiteDefaults\SiteDefaults;
use Statamic\Testing\AddonTestCase;
@@ -104,7 +105,14 @@ protected function setSeoInSiteDefaults($seo)
protected function setSeoOnCollection($collection, $seo)
{
- $collection->cascade(['seo' => $seo])->save();
+ $locale = Site::default()->handle();
+ $localized = SectionDefaults::in('collections', $collection->handle(), $locale);
+
+ if ($seo === false) {
+ SectionDefaults::disable('collections', $collection->handle());
+ } else {
+ $localized->set($seo)->save();
+ }
return $this;
}