Skip to content

fix(seo): correct crashes and wrong output on the meta tag rendering path - #60

Merged
Fredxd merged 7 commits into
AppoloDev:mainfrom
Vondry:fix/seo-metatag-rendering
Jul 27, 2026
Merged

Fredxd merged 7 commits into
AppoloDev:mainfrom
Vondry:fix/seo-metatag-rendering

Conversation

@Vondry

@Vondry Vondry commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes six bugs affecting the path from loading the seo field through to generating the final meta tags. Three caused hard 500 errors on the public frontend, while the others silently produced incorrect or missing metadata.

Each fix was developed test-first: a failing test demonstrating the bug, followed by the implementation and a dedicated commit. The test suite grows from 88 to 112 tests, with all tests passing and ECS and PHPStan clean throughout.

What's fixed

keywords_length: 0 mangled configured keywords (a449c8e)

0 is the shipped default and means the keywords field is disabled in the editor. However, it was still passed to Html::trimText(), which interprets 0 as a real length limit, truncating the final character and appending an ellipsis.

As a result, values from default.keywords and override_default.*.keywords were emitted as:

keyword one, keyword tw…

Truncation is now applied only when the configured limit is greater than zero.

A non-JSON seo field value caused a fatal error (9a1115a)

json_decode() returns null for malformed JSON and scalar values for payloads such as 5 or "text". Those values were assigned directly to the array-typed $seoData property, triggering a TypeError and taking down every frontend page rendering the affected record.

Although the field is normally maintained by the editor's JavaScript, fixtures, imports, and API writes can store arbitrary values. The code now accepts only decoded arrays; everything else is treated as "no SEO data", allowing the normal fallback chain to take over.

Malformed UTF-8 caused a fatal error (f6fb427)

preg_replace('/\s+/u', ...) returns null when given malformed UTF-8 instead of throwing an exception. That null value was returned from a method declared to return string, causing a fatal error.

A single invalid byte in a title or description—for example from a legacy import or malformed paste—could previously take down the entire page.

Missing length configuration keys caused fatal errors (ba1076a)

Bolt copies an extension's default configuration into config/extensions/ only once and never merges it again (ConfigTrait::initializeConfig). As a result, older installations or manually edited configuration files can legitimately be missing newer keys.

A missing description_length was passed to Html::trimText() as null, producing a TypeError under strict_types and a frontend 500.

Both length settings are now read via Collection::get() with documented defaults.

This also highlights an important behaviour for future releases: newly added configuration keys will not automatically appear in existing installations.

Empty configuration defaults short-circuited the fallback chain (1f72662)

ogtype(), robots(), image(), and canonical() checked configured defaults with isset(), so an empty string counted as a configured value.

The commented examples shipped in config/config.yaml include:

canonical: ''
image: ''

Following those examples resulted in empty <link rel="canonical">, og:url, and twitter:url tags being emitted.

The checks now use !empty(), matching the existing behaviour of title() and description().

Keywords were nested inside the description tag (84d1067)

In _metatags.html.twig, the keywords <meta> tag was mistakenly nested inside the description {% if %} block. As a result, pages with keywords but no description emitted neither tag.

Testing

  • vendor/bin/phpunit — 112 tests, 166 assertions, all passing.
  • make cscheck — ECS and PHPStan clean.

Two improvements were made to the test infrastructure:

  • Added tests/Twig/MetaTagsTemplateTest.php. The existing suite mocked Twig\Environment, so _metatags.html.twig had never actually been rendered during testing. The new tests render the template through a real Twig environment with minimal stubs for Bolt's htmllang() function and config global. Five of the six tests passed immediately, serving as regression tests for behaviour that was already correct.
  • Added a mergeDefaults flag to SeoTestCase::makeSeo(). The helper previously always re-merged defaultConfig(), making it impossible to test configurations with genuinely missing keys. The flag defaults to true, so all existing tests remain unchanged.

Behaviour change

Installations currently relying on:

default:
  canonical: ''
  image: ''

to suppress output will now receive the normal fallback values (the current request URI and the record image, respectively). This is the intended fix, but it results in a visible change to the generated metadata.

Vondry added 7 commits July 25, 2026 14:55
`keywords_length: 0` is the shipped default and means the keywords field is
disabled in the editor. It was still passed to Html::trimText(), which treats
0 as a real limit: it chopped the last character off and appended an ellipsis,
so `default.keywords` and `override_default.*.keywords` were emitted as
"keyword one, keyword tw…". Only truncate when a positive limit is set.
json_decode() returns null for malformed JSON and a scalar for payloads like
`5` or `"text"`, and both were assigned straight into the array-typed
$seoData property — a TypeError that took down every frontend page rendering
that record. Only arrays are accepted now; anything else degrades to "no SEO
data" and the regular fallback chain takes over.
preg_replace() with the /u modifier returns null on malformed UTF-8 rather
than throwing, and cleanUp() returned it straight from a `string` method.
One bad byte anywhere in a title or description — a legacy import, a mangled
paste — was a TypeError that took the whole page down. Fall back to the
uncollapsed string instead.
Bolt copies an extension's default config to config/extensions/ exactly once
and never merges it again, so a site's config file can legitimately lack any
key — an older copy, or one the user commented out. A missing
`description_length` reached Html::trimText() as null, which is a TypeError
under strict_types and a 500 on every page. Both length keys are now read via
Collection::get() with the documented defaults and coerced to int.
ogtype(), robots(), image() and canonical() guarded their configured default
with isset(), so an empty string counted as "configured" and short-circuited
the rest of the chain. The commented override examples shipped in
config/config.yaml use `canonical: ''` and `image: ''`, so following the
documentation produced an empty <link rel="canonical">, og:url and
twitter:url. Use !empty() to match how title() and description() already
treat empty strings.
The keywords tag was nested inside the description `if`, so a page with
keywords but no description emitted neither. They are independent tags.

Adds tests/Twig/MetaTagsTemplateTest.php, which renders the template through
a real Twig environment with stubs for the Bolt-provided `htmllang()`
function and `config` global — the Seo tests mock Twig\\Environment, so
nothing previously exercised the template itself.
configLength() fell back to hardcoded 158/0, duplicating config.yaml's
documented defaults. If a site's config file lacks the key, just skip
trimming instead of guessing at a value that belongs in one place.
@Fredxd
Fredxd merged commit ca7fb37 into AppoloDev:main Jul 27, 2026
13 checks passed
@Fredxd

Fredxd commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@Vondry Hi, thanks for PR.
I have merged it ;)

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.

2 participants