Skip to content

Slugless canonical for singletons + clean listing canonical#3735

Open
Vondry wants to merge 2 commits into
bolt:6.2from
Vondry:fix/singleton-canonical-slug
Open

Slugless canonical for singletons + clean listing canonical#3735
Vondry wants to merge 2 commits into
bolt:6.2from
Vondry:fix/singleton-canonical-slug

Conversation

@Vondry

@Vondry Vondry commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Problem

A singleton content type ends up with two indexable URLs serving identical content, and the on-page canonical points at the "ugly" one:

  • Bolt serves a singleton at its slugless listing URL (ListingController forwards a singleton "listing" to the record), e.g. /about.
  • But ContentHelper::getCanonicalRouteAndParams() returns the record-detail route, so the <link rel="canonical"> becomes /about/{slug} — where {slug} is an auto-generated record slug (e.g. about-2) that carries no meaning for a one-record type.

Separately, listing canonicals leak volatile query params: ListingController merges the parsed query params (order, status, filters) into the canonical, producing URLs like /showcases?order=-createdAt&status=published.

Fix

  • Singletons now canonicalize to their slugless listing URL (/{singularSlug}) instead of the record-detail route. The homepage (also a singleton) keeps resolving to the homepage route — its early-return precedence is preserved.
  • Listings no longer fold query params into the canonical; the canonical is the clean listing URL.

Because getLink() (record|link) and setCanonicalPath() share getCanonicalRouteAndParams(), internal links and the canonical tag now agree for singletons.

Changes

  • src/Utils/ContentHelper.php — add a singleton branch to getCanonicalRouteAndParams().
  • src/Controller/Frontend/ListingController.php — stop merging query params into the listing canonical.

Tests

  • Unit (tests/php/Utils/ContentHelperTest.php): singleton → slugless listing route (no slugOrId); non-singleton → record route + slug (regression guard); homepage singleton → homepage route,
    not listing; non-Content input ignored.
  • Functional (tests/php/Controller/Frontend/ListingCanonicalTest.php): a listing canonical omits query params.
  • End-to-end (tests/php/Controller/Frontend/SingletonCanonicalTest.php): a singleton's slugged record URL canonicalizes to its slugless URL, and the slugless URL is self-referential.

Each test was mutation-verified — it fails when its corresponding fix is reverted.

Supporting test changes

  • config/bolt/contenttypes.yaml — add an about non-homepage singleton to the test-scaffolding content types so the end-to-end test has a record to exercise (the only existing singleton, homepage, is special-cased).
  • tests/php/Configuration/Parser/ContentTypesParserTest.php — demo content-type count 9 → 10, assert the about key.
  • phpunit.xml.dist — set BOLT_CANONICAL so the Canonical service bootstraps in the test environment (it otherwise fatals on the empty default value).

Test screenshots

1. Homepage singleton test case (This already works -> nothing changes)

Screenshot 2026-06-18 at 10 49 38 Screenshot 2026-06-18 at 10 36 04

2. Contact page singleton test cases (This did not work -> now works as homepage singleton)

Screenshot 2026-06-18 at 10 50 23 Screenshot 2026-06-18 at 10 36 47
Screenshot 2026-06-18 at 10 54 27 Screenshot 2026-06-18 at 10 37 13

Notes

  • No DB/schema changes — Bolt stores content generically, so the new singleton needs no migration.
  • Full PHP suite passes (195 tests). RelationFactoryTest is occasionally flaky due to unseeded Faker fixtures; unrelated to this change.

Vondry added 2 commits June 18, 2026 10:44
…ing query params

Singletons are served at their slugless listing URL (ListingController
forwards a singleton listing to the record), but getCanonicalRouteAndParams
returned the record-detail route, exposing two URLs for identical content
(/{singularSlug} and /{singularSlug}/{slug}). Canonicalize singletons to the
slugless /{singularSlug} URL instead.

Also stop merging listing query params (order/status/filters) into the
listing canonical, where they appeared as ?order=-createdAt&status=published.
… canonical

Unit tests for ContentHelper canonical route resolution:
- singleton -> slugless listing route (no slugOrId)
- non-singleton -> record route with slug (regression guard)
- homepage singleton -> homepage route, not listing (precedence)
- non-Content input is ignored

Functional tests:
- listing canonical omits volatile query params (order/status/filters)
- a singleton's slugged record URL canonicalizes to its slugless URL, and
  the slugless URL is self-referential (end-to-end)

Support changes:
- add an 'about' non-homepage singleton to the test-scaffolding content types
  so the end-to-end singleton test has a record to exercise
- ContentTypesParserTest: demo content-type count 9 -> 10, assert 'about' key
- phpunit.xml.dist: set BOLT_CANONICAL so the Canonical service bootstraps in
  the test environment (otherwise it fatals on an empty value)
@Vondry Vondry force-pushed the fix/singleton-canonical-slug branch from ed159a1 to f966cb9 Compare June 18, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant