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 @@ + + + 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 @@ + + + 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; }