diff --git a/cms/contents/page/translations/sfs_cms_contents.en.yaml b/cms/contents/page/translations/sfs_cms_contents.en.yaml index 470776b5..8a69db9e 100644 --- a/cms/contents/page/translations/sfs_cms_contents.en.yaml +++ b/cms/contents/page/translations/sfs_cms_contents.en.yaml @@ -105,6 +105,9 @@ admin_page: no: "no" yes: "yes" edit.link: "Edit" + canonicalPage: + label: "Canonical URL source" + empty: "This page is canonical to itself" routes: title: "Routes" cache: "(cache %ttl% seconds)" @@ -307,6 +310,10 @@ admin_page: yearly: "yearly" never: "never" sitemapPriority.label: "URL priority for sitemap" + canonicalPage: + label: "Canonical URL source" + placeholder: "Use this page as canonical (default)" + help: "If selected, this page will output a canonical URL pointing to the selected page." seo: metaTitle.label: "Title (browser title and meta tags)" metaDescription.label: "Description (meta tag)" diff --git a/cms/contents/page/translations/sfs_cms_contents.es.yaml b/cms/contents/page/translations/sfs_cms_contents.es.yaml index f1d10709..9497e161 100644 --- a/cms/contents/page/translations/sfs_cms_contents.es.yaml +++ b/cms/contents/page/translations/sfs_cms_contents.es.yaml @@ -105,6 +105,9 @@ admin_page: no: "no" yes: "sí" edit.link: "Editar" + canonicalPage: + label: "Fuente de URL canónica" + empty: "Esta página es canónica de sí misma" routes: title: "Rutas" cache: "(cache %ttl% segundos)" @@ -307,6 +310,10 @@ admin_page: yearly: "anualmente" never: "nunca" sitemapPriority.label: "Prioridad de URL en el sitemap" + canonicalPage: + label: "Fuente de URL canónica" + placeholder: "Usar esta página como canónica (por defecto)" + help: "Si se selecciona, esta página generará la URL canónica apuntando a la página seleccionada." seo: metaTitle.label: "Título (título del navegador y etiquetas meta)" metaDescription.label: "Descripción (etiqueta meta)" diff --git a/cms/layouts/default/render.html.twig b/cms/layouts/default/render.html.twig index 40db06b5..53463aeb 100644 --- a/cms/layouts/default/render.html.twig +++ b/cms/layouts/default/render.html.twig @@ -21,6 +21,10 @@ + {% set canonicalUrl = sfs_cms_canonical_url() %} + {% if canonicalUrl %} + + {% endif %} {% for locale, url in sfs_cms_alternate_urls() %} {% endfor %} @@ -93,4 +97,4 @@ }); {% endif %} -{% endblock javascripts %} \ No newline at end of file +{% endblock javascripts %} diff --git a/config/doctrine-mapping/entities/Content.orm.xml b/config/doctrine-mapping/entities/Content.orm.xml index 56cdc88a..3b4bc420 100644 --- a/config/doctrine-mapping/entities/Content.orm.xml +++ b/config/doctrine-mapping/entities/Content.orm.xml @@ -50,6 +50,10 @@ + + + + diff --git a/config/validation/ContentInterface.yaml b/config/validation/ContentInterface.yaml index b773b590..b2049f81 100644 --- a/config/validation/ContentInterface.yaml +++ b/config/validation/ContentInterface.yaml @@ -5,3 +5,7 @@ Softspring\CmsBundle\Model\ContentInterface: getters: routes: - Valid: ~ + canonicalPage: + - Symfony\Component\Validator\Constraints\Expression: + expression: "this.getCanonicalPage() == null or this.getCanonicalPage() != this" + message: "Canonical page cannot reference itself" diff --git a/src/Data/EntityTransformer/ContentEntityTransformer.php b/src/Data/EntityTransformer/ContentEntityTransformer.php index 42c393f8..a979a8b0 100644 --- a/src/Data/EntityTransformer/ContentEntityTransformer.php +++ b/src/Data/EntityTransformer/ContentEntityTransformer.php @@ -94,6 +94,7 @@ public function export(object $element, &$files = [], ?object $contentVersion = 'sites' => $content->getSites()->map(fn (SiteInterface $site) => $site->getId())->toArray(), 'extra' => $content->getExtraData(), 'indexing' => $content->getIndexing(), + 'canonical_page' => $content->getCanonicalPage()?->getName(), 'versions' => $versions, ], ]; @@ -138,6 +139,16 @@ public function import(array $data, ReferencesRepository $referencesRepository, $content->setExtraData($contentData['extra']); + if (!empty($contentData['canonical_page'])) { + try { + /** @var ContentInterface $canonicalPage */ + $canonicalPage = $referencesRepository->getReference('content___'.Slugger::lowerSlug($contentData['canonical_page']), true); + $content->setCanonicalPage($canonicalPage); + } catch (ReferenceNotFoundException) { + // ignore references outside the imported fixture set + } + } + if (isset($contentData['seo'])) { $content->setSeo($contentData['seo']); } elseif (isset($contentData['indexing'])) { diff --git a/src/Form/Admin/Content/ContentCreateForm.php b/src/Form/Admin/Content/ContentCreateForm.php index 23a5d34f..b8d20507 100644 --- a/src/Form/Admin/Content/ContentCreateForm.php +++ b/src/Form/Admin/Content/ContentCreateForm.php @@ -2,11 +2,13 @@ namespace Softspring\CmsBundle\Form\Admin\Content; +use Doctrine\ORM\EntityRepository; use Softspring\CmsBundle\Form\Admin\Route\RouteCollectionType; use Softspring\CmsBundle\Form\Admin\SiteChoiceType; use Softspring\CmsBundle\Form\Type\DynamicFormType; use Softspring\CmsBundle\Model\ContentInterface; use Softspring\CmsBundle\Translator\TranslatableContext; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -74,6 +76,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'by_reference' => false, ]); + $builder->add('canonicalPage', EntityType::class, [ + 'class' => $options['content_config']['entity_class'], + 'required' => false, + 'choice_label' => 'name', + 'placeholder' => "admin_{$options['content_config']['_id']}.form.canonicalPage.placeholder", + 'help' => "admin_{$options['content_config']['_id']}.form.canonicalPage.help", + 'query_builder' => fn (EntityRepository $repository) => $repository->createQueryBuilder('c')->orderBy('c.name', 'ASC'), + ]); + if (!empty($options['content_config']['extra_fields'])) { $builder->add('extraData', DynamicFormType::class, [ 'form_fields' => $options['content_config']['extra_fields'], diff --git a/src/Form/Admin/Content/ContentUpdateForm.php b/src/Form/Admin/Content/ContentUpdateForm.php index a1c7fedd..bbe3fcf5 100644 --- a/src/Form/Admin/Content/ContentUpdateForm.php +++ b/src/Form/Admin/Content/ContentUpdateForm.php @@ -2,10 +2,12 @@ namespace Softspring\CmsBundle\Form\Admin\Content; +use Doctrine\ORM\EntityRepository; use Softspring\CmsBundle\Form\Admin\SiteChoiceType; use Softspring\CmsBundle\Form\Type\DynamicFormType; use Softspring\CmsBundle\Model\ContentInterface; use Softspring\CmsBundle\Translator\TranslatableContext; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -72,6 +74,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => new Count(['min' => 1]), ]); + $builder->add('canonicalPage', EntityType::class, [ + 'class' => $options['content_config']['entity_class'], + 'required' => false, + 'choice_label' => 'name', + 'placeholder' => "admin_{$options['content_config']['_id']}.form.canonicalPage.placeholder", + 'help' => "admin_{$options['content_config']['_id']}.form.canonicalPage.help", + 'query_builder' => fn (EntityRepository $repository) => $repository->createQueryBuilder('c') + ->andWhere('c != :content') + ->setParameter('content', $options['content']) + ->orderBy('c.name', 'ASC'), + ]); + if (!empty($options['content_config']['extra_fields'])) { $builder->add('extraData', DynamicFormType::class, [ 'form_fields' => $options['content_config']['extra_fields'], diff --git a/src/Manager/ContentManager.php b/src/Manager/ContentManager.php index aaae84f3..93d355ec 100644 --- a/src/Manager/ContentManager.php +++ b/src/Manager/ContentManager.php @@ -83,6 +83,7 @@ public function duplicateEntity(ContentInterface $content): ContentInterface $newContent->setName($content->getName().' (copy)'); $newContent->setExtraData($content->getExtraData()); $newContent->setIndexing($content->getIndexing()); + $newContent->setCanonicalPage($content->getCanonicalPage()); $newContent->setDefaultLocale($content->getDefaultLocale()); $newContent->setLocales($content->getLocales()); foreach ($content->getSites() as $site) { diff --git a/src/Model/Content.php b/src/Model/Content.php index e395db6c..deb1acaf 100644 --- a/src/Model/Content.php +++ b/src/Model/Content.php @@ -25,6 +25,8 @@ abstract class Content implements ContentInterface */ protected Collection $routes; + protected ?ContentInterface $canonicalPage = null; + protected ?RouteInterface $canonical; protected ?array $extraData = null; @@ -104,6 +106,16 @@ public function removeRoute(RouteInterface $route): void } } + public function getCanonicalPage(): ?ContentInterface + { + return $this->canonicalPage; + } + + public function setCanonicalPage(?ContentInterface $canonicalPage): void + { + $this->canonicalPage = $canonicalPage; + } + public function getCanonicalRoutePath(?string $locale = null): ?RoutePathInterface { // TODO, by now there is not a canonical mark in route paths, so we return the first one diff --git a/src/Model/ContentInterface.php b/src/Model/ContentInterface.php index 2e5268fa..2d2b6a41 100644 --- a/src/Model/ContentInterface.php +++ b/src/Model/ContentInterface.php @@ -39,6 +39,10 @@ public function addRoute(RouteInterface $route): void; public function removeRoute(RouteInterface $route): void; + public function getCanonicalPage(): ?ContentInterface; + + public function setCanonicalPage(?ContentInterface $canonicalPage): void; + public function getCanonicalRoutePath(?string $locale = null): ?RoutePathInterface; public function getExtraData(): ?array; diff --git a/src/Twig/Extension/TranslateExtension.php b/src/Twig/Extension/TranslateExtension.php index 0c801a88..1e628687 100644 --- a/src/Twig/Extension/TranslateExtension.php +++ b/src/Twig/Extension/TranslateExtension.php @@ -40,6 +40,7 @@ public function getFunctions(): array new TwigFunction('sfs_cms_available_locales', [$this, 'getAvailableLocales']), new TwigFunction('sfs_cms_alternate_urls', [$this, 'getAlternateUrls']), new TwigFunction('sfs_cms_locale_paths', [$this, 'getLocalePaths']), + new TwigFunction('sfs_cms_canonical_url', [$this, 'getCanonicalUrl']), ]; } @@ -160,4 +161,34 @@ public function getLocalePaths(?string $defaultRoute = null): array return $localePaths; } + + public function getCanonicalUrl(): ?string + { + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return null; + } + + $site = $request->attributes->get('_sfs_cms_site'); + $locale = $request->getLocale(); + + /** @var ?RoutePathInterface $routePath */ + $routePath = $request->attributes->get('routePath'); + if (!$routePath) { + return null; + } + + $content = $routePath->getRoute()->getContent(); + $canonicalPage = $content?->getCanonicalPage(); + + if ($canonicalPage) { + $canonicalRoutePath = $canonicalPage->getCanonicalRoutePath($locale) ?: $canonicalPage->getCanonicalRoutePath($canonicalPage->getDefaultLocale()); + + if ($canonicalRoutePath) { + return $this->cmsUrlGenerator->getUrlFixed($canonicalRoutePath, $site); + } + } + + return $this->cmsUrlGenerator->getUrlFixed($routePath, $site); + } } diff --git a/templates/admin/content/_read_indexing.html.twig b/templates/admin/content/_read_indexing.html.twig index f022c222..9f5e48f6 100644 --- a/templates/admin/content/_read_indexing.html.twig +++ b/templates/admin/content/_read_indexing.html.twig @@ -38,6 +38,14 @@ {{ ('admin_'~content_type~'.read.indexing.sitemap.no')|trans }} {% endif %} +
{{ ('admin_'~content_type~'.read.indexing.canonicalPage.label')|trans }}
+
+ {% if entity.canonicalPage %} + {{ entity.canonicalPage.name }} + {% else %} + {{ ('admin_'~content_type~'.read.indexing.canonicalPage.empty')|trans }} + {% endif %} +
{% endblock %} -{% endembed %} \ No newline at end of file +{% endembed %} diff --git a/tests/Unit/Entity/PageTest.php b/tests/Unit/Entity/PageTest.php index 4d72b329..918212e6 100644 --- a/tests/Unit/Entity/PageTest.php +++ b/tests/Unit/Entity/PageTest.php @@ -69,6 +69,18 @@ public function testRoutes(): void $this->assertCount(0, $page->getRoutes()); } + public function testCanonicalPage(): void + { + $page = new Page(); + $this->assertNull($page->getCanonicalPage()); + + $page->setCanonicalPage($canonicalPage = new Page()); + $this->assertSame($canonicalPage, $page->getCanonicalPage()); + + $page->setCanonicalPage(null); + $this->assertNull($page->getCanonicalPage()); + } + public function testExtraData(): void { $page = new Page(); @@ -115,4 +127,4 @@ public function testLastVersionNumber(): void $page->setLastVersionNumber(1); $this->assertEquals(1, $page->getLastVersionNumber()); } -} \ No newline at end of file +}