Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions config/bolt/contenttypes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,27 @@ validationdemos:
min: 1
max: 2

# This contenttype is here to use for (automated) tests.
# A non-homepage singleton: Bolt serves a singleton at its slugless listing URL
# (the ListingController forwards a singleton "listing" to the record), so its
# canonical should be `/about`, not the record-detail route `/about/{slug}`.
about:
name: About
singular_name: About
slug: about
fields:
title:
type: text
label: Title
slug:
type: slug
uses: title
group: meta
viewless: false
singleton: true
icon_many: "fa:info-circle"
icon_one: "fa:info-circle"

# Possible field types:
#
# text - varchar(256) - input type text.
Expand Down
2 changes: 2 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<!-- ###+ doctrine/doctrine-bundle ### -->
<env name="DATABASE_URL" value="sqlite:///%kernel.project_dir%/var/data/bolt.test.sqlite" force="true"/>
<!-- ###- doctrine/doctrine-bundle ### -->
<!-- Set canonical in the general config. Keep empty to not use it. -->
<env name="BOLT_CANONICAL" value="http://localhost"/>
<!-- ###+ nelmio/cors-bundle ### -->
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
<!-- ###- nelmio/cors-bundle ### -->
Expand Down
8 changes: 5 additions & 3 deletions src/Controller/Frontend/ListingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,15 @@ public function listing(Request $request, ContentRepository $contentRepository,

$records = $this->setRecords($content, $amountPerPage, $page);

// Set canonical URL
// Set canonical URL. Note: query params (order/status/filters from
// parseQueryParams) are intentionally NOT merged in — they are volatile and
// would pollute the canonical (e.g. ?order=-createdAt&status=published).
$this->canonical->setPath(
'listing_locale',
array_merge([
[
'contentTypeSlug' => $contentType->get('slug'),
'_locale' => $request->getLocale(),
], $params)
]
);

// Render
Expand Down
16 changes: 16 additions & 0 deletions src/Utils/ContentHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ private function getCanonicalRouteAndParams(Content $record, ?string $locale = n
];
}

// Singletons are served at their slugless listing URL: ListingController
// forwards a singleton "listing" to the record. Canonicalize to that URL
// (`/{singularSlug}`) instead of the record-detail route
// (`/{singularSlug}/{slug}`), so a singleton has a single, slugless URL
// rather than two URLs serving identical content.
$definition = $record->getDefinition();
if ($definition !== null && $definition->get('singleton')) {
return [
'route' => 'listing_locale',
'params' => [
'contentTypeSlug' => $record->getContentTypeSingularSlug(),
'_locale' => $locale,
],
];
}

return [
'route' => $record->getDefinition()->get('record_route'),
'params' => [
Expand Down
3 changes: 2 additions & 1 deletion tests/php/Configuration/Parser/ContentTypesParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ public function testHasConfig(): void
$contentTypesParser = new ContentTypesParser($this->getProjectDir(), $generalParser->parse(), self::DEFAULT_LOCALE, self::ALLOWED_LOCALES);
$config = $contentTypesParser->parse();

$this->assertCount(9, $config);
$this->assertCount(10, $config);

$this->assertArrayHasKey('homepage', $config);
$this->assertArrayHasKey('about', $config);
$this->assertCount(self::AMOUNT_OF_ATTRIBUTES_IN_CONTENT_TYPE, $config['homepage']);

$this->assertSame('Homepage', $config['homepage']['name']);
Expand Down
28 changes: 28 additions & 0 deletions tests/php/Controller/Frontend/ListingCanonicalTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Bolt\Tests\Controller\Frontend;

use Bolt\Tests\DbAwareTestCase;

/**
* The listing canonical must be the clean listing URL. Query params parsed for
* the listing (order/status/filters) are volatile and must not leak into the
* <link rel="canonical">.
*/
class ListingCanonicalTest extends DbAwareTestCase
{
public function testListingCanonicalOmitsQueryParams(): void
{
$crawler = $this->client->request('GET', '/showcases?order=title');

$this->assertResponseIsSuccessful();

$canonical = $crawler->filter('link[rel="canonical"]')->attr('href');

$this->assertStringEndsWith('/showcases', $canonical);
$this->assertStringNotContainsString('?', $canonical);
$this->assertStringNotContainsString('order', $canonical);
}
}
45 changes: 45 additions & 0 deletions tests/php/Controller/Frontend/SingletonCanonicalTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Bolt\Tests\Controller\Frontend;

use Bolt\Entity\Content;
use Bolt\Tests\DbAwareTestCase;

/**
* End-to-end: a singleton is reachable both at its slugless URL (`/about`) and at
* the record-detail route (`/about/{slug}`). Both must declare the slugless URL
* as their canonical, so the two URLs consolidate to one.
*/
class SingletonCanonicalTest extends DbAwareTestCase
{
public function testSingletonRecordUrlCanonicalizesToSluglessUrl(): void
{
/** @var Content|null $record */
$record = $this->getEm()->getRepository(Content::class)
->findOneBy(['contentType' => 'about']);

$this->assertNotNull($record, 'Expected the "about" singleton fixture to be seeded.');

$singularSlug = $record->getContentTypeSingularSlug();
$slug = $record->getSlug();
$expectedCanonical = 'http://localhost/' . $singularSlug;

// The slugged record URL must canonicalize to the slugless singleton URL.
$crawler = $this->client->request('GET', sprintf('/%s/%s', $singularSlug, $slug));
$this->assertResponseIsSuccessful();
$this->assertSame(
$expectedCanonical,
$crawler->filter('link[rel="canonical"]')->attr('href')
);

// The slugless URL itself is self-referential (same canonical).
$crawler = $this->client->request('GET', '/' . $singularSlug);
$this->assertResponseIsSuccessful();
$this->assertSame(
$expectedCanonical,
$crawler->filter('link[rel="canonical"]')->attr('href')
);
}
}
142 changes: 142 additions & 0 deletions tests/php/Utils/ContentHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace Bolt\Tests\Utils;

use Bolt\Canonical;
use Bolt\Configuration\Config;
use Bolt\Configuration\Content\ContentType;
use Bolt\Entity\Content;
use Bolt\Twig\LocaleExtension;
use Bolt\Utils\ContentHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

/**
* Canonical route/params resolution, with focus on the singleton handling:
* a singleton is served at its slugless listing URL (ListingController forwards
* a singleton listing to the record), so its canonical must point there
* (`/{singularSlug}`) and not at the record-detail route (`/{singularSlug}/{slug}`).
*/
class ContentHelperTest extends TestCase
{
public function testSingletonCanonicalUsesSluglessListingRoute(): void
{
$content = $this->createContent(
singleton: true,
singularSlug: 'contact',
slug: 'contact-info',
recordSlug: 'contact-2'
);

$canonical = $this->createMock(Canonical::class);
$canonical->expects($this->once())
->method('setPath')
->with('listing_locale', [
'contentTypeSlug' => 'contact',
'_locale' => 'en',
]);

$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
}

public function testNonSingletonCanonicalUsesRecordRouteWithSlug(): void
{
$content = $this->createContent(
singleton: false,
singularSlug: 'entry',
slug: 'entries',
recordSlug: 'my-post',
recordRoute: 'record'
);

$canonical = $this->createMock(Canonical::class);
$canonical->expects($this->once())
->method('setPath')
->with('record', [
'contentTypeSlug' => 'entry',
'slugOrId' => 'my-post',
'_locale' => 'en',
]);

$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
}

public function testHomepageCanonicalIsNotAffectedBySingletonHandling(): void
{
// The homepage is itself a singleton, but must keep resolving to the
// homepage route, not the listing route.
$content = $this->createContent(
singleton: true,
singularSlug: 'homepage',
slug: 'homepage',
recordSlug: 'homepage'
);

$canonical = $this->createMock(Canonical::class);
$canonical->expects($this->once())
->method('setPath')
->with('homepage_locale', [
'_locale' => 'en',
]);

$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
}

public function testNonContentIsIgnored(): void
{
$canonical = $this->createMock(Canonical::class);
$canonical->expects($this->never())
->method('setPath');

$this->createContentHelper($canonical)->setCanonicalPath(null, 'en');
}

private function createContent(
bool $singleton,
string $singularSlug,
string $slug,
string $recordSlug,
?string $recordRoute = null
): Content {
$definition = $this->createMock(ContentType::class);
$definition->method('get')->willReturnCallback(
static fn (string $key) => match ($key) {
'singleton' => $singleton,
'record_route' => $recordRoute,
default => null,
}
);

$content = $this->createMock(Content::class);
$content->method('getDefinition')->willReturn($definition);
$content->method('getContentTypeSingularSlug')->willReturn($singularSlug);
$content->method('getContentTypeSlug')->willReturn($slug);
$content->method('getSlug')->willReturn($recordSlug);
$content->method('getId')->willReturn(1);

return $content;
}

private function createContentHelper(Canonical $canonical): ContentHelper
{
$config = $this->createMock(Config::class);
// Only `general/homepage` is consulted (via isHomepage()); a content type
// is the homepage when its slug matches this value.
$config->method('get')->willReturnCallback(
static fn (string $path) => $path === 'general/homepage' ? 'homepage' : null
);

$requestStack = $this->createMock(RequestStack::class);
$requestStack->method('getCurrentRequest')->willReturn(new Request());

return new ContentHelper(
$canonical,
$requestStack,
$config,
$this->createMock(LocaleExtension::class)
);
}
}
Loading