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