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

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
Vondry force-pushed the fix/singleton-canonical-slug branch from ed159a1 to f966cb9 Compare June 18, 2026 08:45
@bobvandevijver bobvandevijver added this to the Bolt 6.2 milestone Jul 4, 2026
@Vondry
Vondry force-pushed the fix/singleton-canonical-slug branch from f966cb9 to 26334a0 Compare August 17, 2026 11:22
…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 26334a0 to 0a94547 Compare September 29, 2026 11:45
Vondry added a commit to Vondry/bolt-skills that referenced this pull request Oct 26, 2026
… is released

Up to bolt/core 6.1.10, Bolt's canonical names a singleton by its record
route (/kontakt/kontakt-2 instead of /kontakt) and keeps the listing query
(/clanky?order=-createdAt&status=published). bolt-seo 2.3 builds og:url
and twitter:url from that canonical.

Add section 1.6 and three theme partials (examples/_canonical.macros.twig,
_canonical.twig, _seo_metatags.twig). They correct the canonical the way
bolt/core#3735 does, and both <link rel="canonical">
(Bolt's canonical_template) and bolt-seo's meta tags (templates.meta) print
it, so the three tags always name the same URL. The section says when to
remove the override: once the project runs a bolt/core release with #3735.
The partials are byte-identical to the five sites that use them.

Also:
- appolodev/bolt-seo ^2.3. The example config and section 1.3 use
  default.robots instead of the dead meta_robots.
- Section 1.5: pages that include @seo/_metatags.html.twig themselves pass
  canonical: seo.canonical(), not app.request.uri (which keeps the query).
- verify.sh step 7 checks the override, and step 2 warns about the dead
  meta_robots / allow keys. WORKFLOWS 3.1, 6.2 and Appendix B.10, the
  bolt-audit checklist, AGENTS.md and README follow.
Vondry added a commit to Vondry/bolt-sitemap that referenced this pull request Oct 27, 2026
/sitemap.xml is a sitemap index with one child sitemap per segment: the
homepage, singletons and static routes (/sitemap-main.xml), each
ContentType (/sitemap-<slug>.xml, split at chunk_size URLs) and the
configured taxonomies. It follows sitemap-xml-specification-v3.md:

- one <url> per page and locale, with the complete, reciprocal set of
  hreflang alternates and x-default;
- singletons at their slugless page, on the listing route with their
  singular slug, as bolt/core#3735 makes their canonical URL; a custom
  record_route is kept as is (from bolt/sitemap-extension#5);
- TemplateController routes with their {_locale} twins as alternates
  (from bolt/sitemap-extension#4), without the template file time as
  <lastmod>, which is the time of the last deploy;
- listing pages only while they show published records, with
  alternates on the listing route;
- <lastmod> from the record in UTC; for listing pages and child
  sitemaps also the last time a URL was added or removed, kept in
  var/data/tomvondracek-sitemap.json; none where no date is known;
- no changefreq, priority, image:title or XSLT; image and xhtml
  namespaces only where used; absolute, percent-encoded URLs on the
  canonical host;
- application/xml, X-Robots-Tag: noindex and a content-hash ETag; the
  XML is cached until content changes.

With appolodev/bolt-seo installed, pages it marks noindex or gives
another canonical URL are left out. Only its config and field values
are read, never its classes, so it stays optional; a contract test
checks the result against bolt-seo's own Seo class.

The XML is rendered from Twig templates a theme can override, and
SitemapUrlsEvent lets a project add or remove URLs. An opt-in
stylesheet and script show the sitemap to people in a browser without
XSLT.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants