diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 08e37fdeb4..ae1f767165 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -89,8 +89,8 @@ jobs:
TOKEN: ${{ secrets.EZROBOT_PAT }}
run: |
curl -H "Authorization: token $TOKEN" -L https://github.com/ibexa/vale-styles/archive/refs/heads/main.zip -o vale.zip
+ rm -rf tests
unzip vale.zip
- rm vale.zip
mv vale-styles-main/* vale-styles-main/.vale.ini .
- name: Run Vale.sh
diff --git a/.github/workflows/code_samples.yaml b/.github/workflows/code_samples.yaml
index d410a86455..74f593675b 100644
--- a/.github/workflows/code_samples.yaml
+++ b/.github/workflows/code_samples.yaml
@@ -53,6 +53,9 @@ jobs:
- name: Run Rector check
run: composer check-rector
+ - name: Run PHPUnit tests
+ run: composer phpunit
+
code-samples-inclusion-check:
name: Check code samples inclusion
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index d243a010bd..10f1a32e27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ auth.json
yarn.lock
docs/css/*.map
.deptrac.cache
+.phpunit.result.cache
diff --git a/code_samples/back_office/components/twig_components.yaml b/code_samples/back_office/components/twig_components.yaml
index 6e0458f4d0..20d8c3caa2 100644
--- a/code_samples/back_office/components/twig_components.yaml
+++ b/code_samples/back_office/components/twig_components.yaml
@@ -12,7 +12,6 @@ ibexa_twig_components:
priority: 0
arguments:
content: 'Hello world!'
- admin-ui-user-menu:
duplicated_user_menu:
type: menu
arguments:
diff --git a/code_samples/back_office/online_editor/config/packages/custom_plugin.yaml b/code_samples/back_office/online_editor/config/packages/custom_plugin.yaml
deleted file mode 100644
index 426b9b75b9..0000000000
--- a/code_samples/back_office/online_editor/config/packages/custom_plugin.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-ibexa:
- system:
- admin_group:
- fieldtypes:
- ibexa_richtext:
- toolbars:
- paragraph:
- buttons:
- date:
- priority: 0
-ibexa_fieldtype_richtext:
- alloy_editor:
- extra_plugins: [date]
diff --git a/code_samples/front/shop/order-management/config/packages/ibexa.yaml b/code_samples/front/shop/order-management/config/packages/ibexa.yaml
index e17cfbc9e5..310eaca7c3 100644
--- a/code_samples/front/shop/order-management/config/packages/ibexa.yaml
+++ b/code_samples/front/shop/order-management/config/packages/ibexa.yaml
@@ -65,7 +65,7 @@ framework:
to:
- dropped
-// ...
+# ...
ibexa:
repositories:
diff --git a/code_samples/front/shop/payment/src/bundle/Resources/config/services/payment_method.yaml b/code_samples/front/shop/payment/src/bundle/Resources/config/services/payment_method.yaml
index b179640c05..ef3b33c817 100644
--- a/code_samples/front/shop/payment/src/bundle/Resources/config/services/payment_method.yaml
+++ b/code_samples/front/shop/payment/src/bundle/Resources/config/services/payment_method.yaml
@@ -8,7 +8,7 @@ services:
$domain:
tags:
- { name: ibexa.payment.payment_method.type, alias: new_payment_method_type }
-services:
+
App\Payment\PaymentMethod\Voter\NewPaymentMethodTypeVoter:
tags:
- - { name: ibexa.payment.payment_method.voter, type: new_payment_method_type }
\ No newline at end of file
+ - { name: ibexa.payment.payment_method.voter, type: new_payment_method_type }
diff --git a/code_samples/recommendations/config/packages/ibexa_connector_raptor.yaml b/code_samples/recommendations/config/packages/ibexa_connector_raptor.yaml
index 61c90252b1..1898c35aae 100644
--- a/code_samples/recommendations/config/packages/ibexa_connector_raptor.yaml
+++ b/code_samples/recommendations/config/packages/ibexa_connector_raptor.yaml
@@ -3,14 +3,14 @@ ibexa:
:
connector_raptor:
enabled: true
- customer_id: ~ # Required
+ customer_id: "12345" # Required
tracking_type: client # One of: "client" or "server"
# Raptor Recommendations API key
- recommendations_api_key: ~ # Required
+ recommendations_api_key: "your_api_key_here" # Required
- # Raptor Recommendations API URL, optional, set by default
- recommendations_api_url: '%ibexa.connector.raptor.recommendations.api_url%'
+ # Raptor Recommendations API URI, optional, set by default
+ recommendations_api_uri: '%ibexa.connector.raptor.recommendations.api_uri%'
ibexa_connector_raptor:
# When enabled, tracking exceptions are thrown instead of being silently handled
strict_exceptions: true
diff --git a/composer.json b/composer.json
index e9415fc8fd..351e081fec 100644
--- a/composer.json
+++ b/composer.json
@@ -4,6 +4,9 @@
"type": "library",
"license": "GNU General Public License v2.0",
"autoload-dev": {
+ "psr-4": {
+ "Ibexa\\Tests\\Documentation\\": "tests/"
+ }
},
"repositories": [
{
@@ -15,6 +18,9 @@
"php": "^8.3"
},
"require-dev": {
+ "phpunit/phpunit": "^11.0",
+ "symfony/yaml": "^7.0",
+ "ibexa/connector-gemini": "5.0.x-dev",
"ibexa/automated-translation": "5.0.x-dev",
"ibexa/code-style": "~2.0.0",
"friendsofphp/php-cs-fixer": "^3.30",
@@ -51,7 +57,7 @@
"ibexa/page-builder": "5.0.x-dev",
"ibexa/order-management": "5.0.x-dev",
"ibexa/calendar": "5.0.x-dev",
- "ibexa/payment": "5.0.x-dev",
+ "ibexa/payment": "~5.0.x-dev",
"ibexa/shipping": "5.0.x-dev",
"ibexa/fieldtype-matrix": "5.0.x-dev",
"ibexa/storefront": "5.0.x-dev",
@@ -85,21 +91,32 @@
"ibexa/cdp": "~5.0.x-dev",
"ibexa/connector-raptor": "~5.0.x-dev",
"ibexa/image-editor": "~5.0.x-dev",
- "ibexa/integrated-help": "~5.0.x-dev"
+ "ibexa/integrated-help": "~5.0.x-dev",
+ "ibexa/site-context": "~5.0.x-dev",
+ "ibexa/fieldtype-richtext-rte": "~5.0.x-dev",
+ "ibexa/site-factory": "~5.0.x-dev",
+ "ibexa/ckeditor-premium": "~5.0.x-dev",
+ "ibexa/measurement": "~5.0.x-dev",
+ "ibexa/connector-actito": "~5.0.x-dev",
+ "ibexa/fastly": "~5.0.x-dev"
},
"scripts": {
"fix-cs": "php-cs-fixer fix --config=.php-cs-fixer.php -v --show-progress=dots",
"check-cs": "@fix-cs --dry-run",
"phpstan": "phpstan analyse",
"deptrac": "deptrac analyse",
- "check-rector": "rector process --dry-run --ansi"
+ "check-rector": "rector process --dry-run --ansi",
+ "phpunit": "phpunit",
+ "phpunit-update-baseline": "php tests/generate-yaml-baseline.php"
},
"scripts-descriptions": {
"fix-cs": "Automatically fixes code style in all files",
"check-cs": "Run code style checker for all files",
"phpstan": "Run static code analysis",
"deptrac": "Run Deptrac architecture testing",
- "check-rector": "Check for code refactoring opportunities"
+ "check-rector": "Check for code refactoring opportunities",
+ "phpunit": "Run PHPUnit tests (YAML validation)",
+ "phpunit-update-baseline": "Regenerate tests/yaml-validation-baseline.yaml from current failures"
},
"config": {
"allow-plugins": false
diff --git a/docs/administration/back_office/customize_search_suggestion.md b/docs/administration/back_office/customize_search_suggestion.md
index 158b3384c2..2764115e25 100644
--- a/docs/administration/back_office/customize_search_suggestion.md
+++ b/docs/administration/back_office/customize_search_suggestion.md
@@ -17,8 +17,9 @@ ibexa:
system:
:
search:
- min_query_length: 3
- result_limit: 5
+ suggestion:
+ min_query_length: 3
+ result_limit: 5
```
## Add custom suggestion source
diff --git a/docs/administration/configuration/dynamic_configuration.md b/docs/administration/configuration/dynamic_configuration.md
index 09f9cfbf42..c847e0899d 100644
--- a/docs/administration/configuration/dynamic_configuration.md
+++ b/docs/administration/configuration/dynamic_configuration.md
@@ -18,7 +18,7 @@ parameters:
# Internal configuration
ibexa.site_access.config.default.content.default_ttl: 60
ibexa.site_access.config.site_group.content.default_ttl: 3600
-
+
# Here "myapp" is the namespace, followed by the SiteAccess name as the parameter scope
# Parameter "my_param" will have a different value in site_group and admin_group
myapp.site_group.my_param: value
diff --git a/docs/api/graphql/graphql_customization.md b/docs/api/graphql/graphql_customization.md
index 5ebf97b4f0..be6421d049 100644
--- a/docs/api/graphql/graphql_customization.md
+++ b/docs/api/graphql/graphql_customization.md
@@ -68,9 +68,9 @@ Mutation:
createSomething:
builder: Mutation
builderConfig:
- inputType: CreateSomethingInput
- payloadType: SomethingPayload
- mutateAndGetPayload: '@=mutation('CreateSomething', [value])'
+ inputType: CreateSomethingInput
+ payloadType: SomethingPayload
+ mutateAndGetPayload: "@=mutation('CreateSomething', [value])"
CreateSomethingInput:
type: relay-mutation-input
diff --git a/docs/api/rest_api/rest_api_authentication.md b/docs/api/rest_api/rest_api_authentication.md
index f7f77366f8..82b504a010 100644
--- a/docs/api/rest_api/rest_api_authentication.md
+++ b/docs/api/rest_api/rest_api_authentication.md
@@ -325,10 +325,15 @@ For more information, see [HTTP Authentication: Basic and Digest Access Authenti
If the installation has a dedicated host for REST, you can enable HTTP basic authentication only on this host by setting a firewall like in the following example before the `ibexa_front` one:
```yaml
+security:
+ firewalls:
+ # ...
ibexa_rest:
host: ^api\.example\.com$
http_basic:
realm: Ibexa DXP REST API
+ #ibexa_front:
+ # ...
```
!!! caution "Back office uses REST API"
diff --git a/docs/cdp/cdp_installation.md b/docs/cdp/cdp_installation.md
index 73ba9dd5ae..f84719a45e 100644
--- a/docs/cdp/cdp_installation.md
+++ b/docs/cdp/cdp_installation.md
@@ -27,11 +27,14 @@ Symfony Flex installs and activates the package.
After an installation process is finished, go to `config/packages/security.yaml` and uncomment `ibexa_cdp` rule.
```yaml
-ibexa_cdp:
- pattern: /cdp/webhook
- guard:
- authenticator: 'Ibexa\Cdp\Security\CdpRequestAuthenticator'
- stateless: true
+security:
+ firewalls:
+ # ...
+ ibexa_cdp:
+ request_matcher: Ibexa\Cdp\Security\RequestMatcher
+ custom_authenticators:
+ - 'Ibexa\Cdp\Security\CdpRequestAuthenticator'
+ stateless: true
```
Now, you can configure [[= product_name_cdp =]].
diff --git a/docs/commerce/checkout/reorder.md b/docs/commerce/checkout/reorder.md
index 4d022782e4..96f83c6bed 100644
--- a/docs/commerce/checkout/reorder.md
+++ b/docs/commerce/checkout/reorder.md
@@ -54,11 +54,11 @@ framework:
places:
!php/const Ibexa\OrderManagement\Value\Status::COMPLETED_PLACE:
metadata:
- ...
+ # ...
can_be_reordered: true
!php/const Ibexa\OrderManagement\Value\Status::CANCELLED_PLACE:
metadata:
- ...
+ # ...
can_be_reordered: true
```
diff --git a/docs/commerce/payment/enable_paypal_payments.md b/docs/commerce/payment/enable_paypal_payments.md
index 3bccfdcdc5..2e4b890c25 100644
--- a/docs/commerce/payment/enable_paypal_payments.md
+++ b/docs/commerce/payment/enable_paypal_payments.md
@@ -42,5 +42,4 @@ ibexa:
type:
pp_express_checkout:
name: "Translated PayPal Express Checkout name"
-
```
diff --git a/docs/commerce/payment/enable_stripe_payments.md b/docs/commerce/payment/enable_stripe_payments.md
index 105ed62402..5d03bd8829 100644
--- a/docs/commerce/payment/enable_stripe_payments.md
+++ b/docs/commerce/payment/enable_stripe_payments.md
@@ -43,5 +43,4 @@ ibexa:
type:
strp_checkout:
name: "Translated Stripe Checkout name"
-
```
diff --git a/docs/commerce/payment/payum_integration.md b/docs/commerce/payment/payum_integration.md
index 7da7da20b1..408515f257 100644
--- a/docs/commerce/payment/payum_integration.md
+++ b/docs/commerce/payment/payum_integration.md
@@ -43,7 +43,7 @@ ibexa_connector_payum:
refunded: cancelled
captured: pending
authorized: authorized
-[...]
+# ...
```
## Payment service name translations
diff --git a/docs/commerce/storefront/configure_storefront.md b/docs/commerce/storefront/configure_storefront.md
index bdc96c02c8..5d69a73251 100644
--- a/docs/commerce/storefront/configure_storefront.md
+++ b/docs/commerce/storefront/configure_storefront.md
@@ -94,9 +94,10 @@ Settings for a Storefront user are configured under the `ibexa.system..st
ibexa:
system:
site_group:
- user_settings_groups:
- - location
- - custom_group
+ storefront:
+ user_settings_groups:
+ - location
+ - custom_group
```
By default, only the `location` user settings is provided:
diff --git a/docs/content_management/collaborative_editing/configure_collaborative_editing.md b/docs/content_management/collaborative_editing/configure_collaborative_editing.md
index 482040f3e1..268f774ff4 100644
--- a/docs/content_management/collaborative_editing/configure_collaborative_editing.md
+++ b/docs/content_management/collaborative_editing/configure_collaborative_editing.md
@@ -57,14 +57,15 @@ security:
```yaml
security:
# ...
- ibexa_shareable_link:
- request_matcher: Ibexa\Collaboration\Security\RequestMatcher\ShareableLinkRequestMatcher
- pattern: ^/
- provider: shared
- stateless: true
- user_checker: Ibexa\Core\MVC\Symfony\Security\UserChecker
- custom_authenticators:
- - Ibexa\Collaboration\Security\Authenticator\ShareableLinkAuthenticator
+ firewalls:
+ ibexa_shareable_link:
+ request_matcher: Ibexa\Collaboration\Security\RequestMatcher\ShareableLinkRequestMatcher
+ pattern: ^/
+ provider: shared
+ stateless: true
+ user_checker: Ibexa\Core\MVC\Symfony\Security\UserChecker
+ custom_authenticators:
+ - Ibexa\Collaboration\Security\Authenticator\ShareableLinkAuthenticator
```
### Configuration
diff --git a/docs/content_management/data_migration/managing_migrations.md b/docs/content_management/data_migration/managing_migrations.md
index 072a7a0e6d..c96e078208 100644
--- a/docs/content_management/data_migration/managing_migrations.md
+++ b/docs/content_management/data_migration/managing_migrations.md
@@ -50,7 +50,7 @@ You can configure a different folder by using the following settings:
``` yaml
ibexa_migrations:
- migration_directory: %kernel.project_dir%/src/Migrations/MyMigrations/
+ migration_directory: '%kernel.project_dir%/src/Migrations/MyMigrations/'
migrations_files_subdir: migration_files
```
@@ -64,7 +64,6 @@ ibexa_migrations:
``` yaml
ibexa_migrations:
migration_directory: '%kernel.project_dir%/data/'
- ...
```
Then, when you run the migration command, you must use the [`--siteaccess` option](exporting_data.md#siteaccess) and provide the name of the SiteAccess that you want to migrate.
diff --git a/docs/content_management/field_types/field_type_storage.md b/docs/content_management/field_types/field_type_storage.md
index ed3ab221c3..2aa8816b52 100644
--- a/docs/content_management/field_types/field_type_storage.md
+++ b/docs/content_management/field_types/field_type_storage.md
@@ -147,7 +147,7 @@ services:
autoconfigure: true
public: false
- App\FieldType\MyField\Storage\MyFieldStorage: ~
+ App\FieldType\MyField\Storage\MyFieldStorage:
tags:
- {name: ibexa.field_type.storage.external.handler, alias: myfield}
```
diff --git a/docs/content_management/field_types/form_and_template.md b/docs/content_management/field_types/form_and_template.md
index 471cdf36c9..9babd387cc 100644
--- a/docs/content_management/field_types/form_and_template.md
+++ b/docs/content_management/field_types/form_and_template.md
@@ -190,7 +190,7 @@ If you don't use the design engine, apply the following configuration:
``` yaml
ibexa:
- systems:
+ system:
admin_group:
field_templates:
- { template: 'adminui/field/custom_field_view.html.twig', priority: 10 }
diff --git a/docs/content_management/images/add_image_asset_from_dam.md b/docs/content_management/images/add_image_asset_from_dam.md
index 8c7c236fd3..52b91bbc7c 100644
--- a/docs/content_management/images/add_image_asset_from_dam.md
+++ b/docs/content_management/images/add_image_asset_from_dam.md
@@ -49,15 +49,15 @@ Next, in `config/packages/ibexa.yaml`, set the `dam.html.twig` template for the
For more information about displaying content, see [Content rendering](render_content.md).
``` yaml
- ibexa:
- system:
- site:
- content_view:
- embed:
- image_dam:
- template: '@ibexadesign/embed/dam.html.twig'
- match:
- Identifier\ContentType:
+ibexa:
+ system:
+ site:
+ content_view:
+ embed:
+ image_dam:
+ template: '@ibexadesign/embed/dam.html.twig'
+ match:
+ Identifier\ContentType:
```
In your [configuration file](configuration.md#configuration-files) add the following configuration:
diff --git a/docs/content_management/taxonomy/taxonomy.md b/docs/content_management/taxonomy/taxonomy.md
index f01ddb75df..118b787a18 100644
--- a/docs/content_management/taxonomy/taxonomy.md
+++ b/docs/content_management/taxonomy/taxonomy.md
@@ -191,10 +191,9 @@ By default, the system returns three suggestions.
You can change the default number if needed by altering the following setting:
``` yaml hl_lines="4"
-ibexa:
- taxonomy:
+ibexa_taxonomy:
text_to_taxonomy:
- default_suggested_taxonomies_limit: 5
+ default_suggested_taxonomies_limit: 5
```
You can also override this setting per AI action by editing its configuration.
diff --git a/docs/content_management/url_management/url_management.md b/docs/content_management/url_management/url_management.md
index a249b83d0e..a9416514a5 100644
--- a/docs/content_management/url_management/url_management.md
+++ b/docs/content_management/url_management/url_management.md
@@ -67,13 +67,13 @@ ibexa:
url_checker:
handlers:
http:
- enabled: true
- batch_size: 64
+ enabled: true
+ batch_size: 64
https:
- enabled: true
- ignore_certificate: false
+ enabled: true
+ ignore_certificate: false
mailto:
- enabled: false
+ enabled: false
```
Available options are protocol-specific.
@@ -128,7 +128,6 @@ Then you must register the service with an `ibexa.url_checker.handler` tag, like
```yaml
app.url_checker.handler.custom:
class: 'App\URLChecker\Handler\CustomHandler'
- ...
tags:
- { name: ibexa.url_checker.handler, scheme: custom }
```
diff --git a/docs/content_management/user_generated_content.md b/docs/content_management/user_generated_content.md
index 4a12ecc3bf..a35612334e 100644
--- a/docs/content_management/user_generated_content.md
+++ b/docs/content_management/user_generated_content.md
@@ -56,14 +56,17 @@ For example, `/content/edit/draft/1/5/eng-GB` enables you to edit draft 5 of con
You can use custom templates for the content editing forms.
-Define the templates under the `ibexa.system..content_edit.templates` [configuration key](configuration.md#configuration-files):
+Define the templates under the `ibexa.system..content_edit_view` [configuration key](configuration.md#configuration-files):
``` yaml
ibexa:
system:
default:
- content_edit:
- templates:
- edit: content/edit/content_edit.html.twig
- create_draft: content/edit/content_create_draft.html.twig
+ content_edit_view:
+ full:
+ :
+ template: content/edit/content_edit.html.twig
+ match: true
+ params:
+ viewbaseLayout: '@ibexadesign/ui/layout.html.twig'
```
diff --git a/docs/customer_management/cp_page_builder.md b/docs/customer_management/cp_page_builder.md
index 69e3bdd6b6..d8673c5b55 100644
--- a/docs/customer_management/cp_page_builder.md
+++ b/docs/customer_management/cp_page_builder.md
@@ -51,7 +51,7 @@ ibexa:
languages: [ eng-GB ]
content:
tree_root:
- location_id: location_id_of_customer_portal
+ location_id: 9999 # location_id_of_customer_portal
excluded_uri_prefixes: [ /media/, /images/ ]
```
@@ -135,7 +135,7 @@ ibexa:
languages: [ eng-GB ]
content:
tree_root:
- location_id: location_id_of_customer_portals_root_folder
+ location_id: 9999 # location_id_of_customer_portals_root_folder
excluded_uri_prefixes: [ /media/, /images/ ]
```
@@ -233,7 +233,7 @@ ibexa:
page_layout: "@App/my_page_layout.html.twig"
content:
tree_root:
- location_id: location_id_of_customer_portals_root_folder
+ location_id: 999 #location_id_of_customer_portals_root_folder
excluded_uri_prefixes: [ /media/, /images/ ]
```
diff --git a/docs/infrastructure_and_maintenance/cache/http_cache/content_aware_cache.md b/docs/infrastructure_and_maintenance/cache/http_cache/content_aware_cache.md
index d6ab0444a1..ae176fac6d 100644
--- a/docs/infrastructure_and_maintenance/cache/http_cache/content_aware_cache.md
+++ b/docs/infrastructure_and_maintenance/cache/http_cache/content_aware_cache.md
@@ -291,7 +291,7 @@ With the same content structure as above, the `[Child]` location is moved below
The new structure is then:
-```yaml
+```text
- [Home] (content-id=52, location-id=2)
ez-all c52 ct42 l2 pl1 p1 p2
|
diff --git a/docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md b/docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md
index f309604195..bd8f0921b0 100644
--- a/docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md
+++ b/docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md
@@ -142,6 +142,9 @@ ibexa:
If the Varnish server is protected by Basic Auth, specify the Basic Auth credentials within the `purge_servers` setting using the format:
``` yaml
+ibexa:
+ system:
+ my_siteaccess_group:
http_cache:
purge_servers: [http://myuser:mypasswd@my.varnish.server:8081]
```
diff --git a/docs/multisite/site_factory/site_factory.md b/docs/multisite/site_factory/site_factory.md
index 74bed2fefb..3afd0628e9 100644
--- a/docs/multisite/site_factory/site_factory.md
+++ b/docs/multisite/site_factory/site_factory.md
@@ -204,7 +204,7 @@ Keep in mind that with disabled Site Factory you're unable to add new sites or u
doctrine:
dbal:
connections:
- ...
+ # ...
# This connection is dedicated for SiteFactory to avoid known issues
site_factory:
```
@@ -214,7 +214,7 @@ doctrine:
``` yaml
framework:
cache:
- ...
+ # ...
pools:
# This pool should be used only by SiteFactory bundle
site_factory_pool:
diff --git a/docs/recommendations/raptor_integration/connector_installation_configuration.md b/docs/recommendations/raptor_integration/connector_installation_configuration.md
index dfc2645400..3e133a440f 100644
--- a/docs/recommendations/raptor_integration/connector_installation_configuration.md
+++ b/docs/recommendations/raptor_integration/connector_installation_configuration.md
@@ -35,7 +35,7 @@ To configure the Raptor connector, use the `ibexa.system..connector_rapto
- `client` - tracking is executed in the browser using JavaScript snippets generated by the [Twig functions](recommendations_twig_functions.md) and included in the templates. This approach may be blocked by ad blockers.
- `server` - tracking is handled on the backend, with events sent directly to the tracking API. It's not affected by ad blockers.
- `recommendations_api_key` - an API key used to authenticate requests to the Recommendations API. This key allows the connector to retrieve personalized recommendations from the recommendation engine. You can find this value as ["API key"](connector_installation_configuration.md#recommendations-api-key) in Raptor Control Panel.
-- `recommendations_api_url` (optional) - overrides the default Raptor address, do not set it unless a custom endpoint is required.
+- `recommendations_api_uri` (optional) - overrides the default Raptor address, do not set it unless a custom endpoint is required.
By default, `tracking_type` is set to `client` as client-side tracking is the standard Raptor mode.
To understand the differences between client and server tracking types, including their advantages and disadvantages, refer to the [Raptor documentation](https://content.raptorservices.com/help-center/client-side-vs.-server-side-tracking).
diff --git a/docs/release_notes/ez_platform_v2.4.md b/docs/release_notes/ez_platform_v2.4.md
index 90562b8ada..16d92cbdb2 100644
--- a/docs/release_notes/ez_platform_v2.4.md
+++ b/docs/release_notes/ez_platform_v2.4.md
@@ -197,18 +197,18 @@ The biggest benefit of this feature is saving load time on complex landing pages
2\. Add the following configuration to `/app/config/config.yml`
``` yaml
- lexik_jwt_authentication:
- secret_key: '%secret%'
- encoder:
+ lexik_jwt_authentication:
+ secret_key: '%secret%'
+ encoder:
signature_algorithm: HS256
# Disabled by default, because Page Builder uses custom extractor
- token_extractors:
- authorization_header:
- enabled: false
- cookie:
- enabled: false
- query_parameter:
- enabled: false
+ token_extractors:
+ authorization_header:
+ enabled: false
+ cookie:
+ enabled: false
+ query_parameter:
+ enabled: false
```
By default `HS256` is used as signature algorithm for generated token but we strongly recommend switching to SSH keys.
@@ -218,23 +218,23 @@ The biggest benefit of this feature is saving load time on complex landing pages
3\. Add `EzSystems\EzPlatformPageBuilder\Security\EditorialMode\TokenAuthenticator` authentication provider to `ezpublish_front` firewall before `form_login` in `app/config/security.yml`:
``` yaml
- security:
+ security:
# ...
- firewalls:
- ezpublish_front:
- # ...
- simple_preauth:
- authenticator: 'EzSystems\EzPlatformPageBuilder\Security\EditorialMode\TokenAuthenticator'
- form_login:
- require_previous_session: false
- # ...
+ firewalls:
+ ezpublish_front:
+ # ...
+ simple_preauth:
+ authenticator: 'EzSystems\EzPlatformPageBuilder\Security\EditorialMode\TokenAuthenticator'
+ form_login:
+ require_previous_session: false
+ # ...
```
4\. Make sure that parameter `page_builder.token_authenticator.enabled` has value `true`. If the parameter isn't present, add it to `/app/config/config.yml`:
``` yaml
- # ...
- parameters:
+ # ...
+ parameters:
# ...
page_builder.token_authenticator.enabled: true
```
diff --git a/docs/search/search_engines/elasticsearch/configure_elasticsearch.md b/docs/search/search_engines/elasticsearch/configure_elasticsearch.md
index 3e05c45665..38c9cf33bb 100644
--- a/docs/search/search_engines/elasticsearch/configure_elasticsearch.md
+++ b/docs/search/search_engines/elasticsearch/configure_elasticsearch.md
@@ -182,7 +182,7 @@ If your Elasticsearch server is protected by HTTP authentication, you must provi
In the basic authentication, you must pass the following parameters:
``` yaml
-
+:
# ...
authentication:
type: basic
@@ -377,14 +377,14 @@ Index names use the following pattern:
You can create index templates with settings that apply to a specific language only, for example, to eliminate stop words from the index, or help divide concatenations.
You use patterns to identify index templates that contain settings specific for a given language:
- ``` yaml
- ibexa_elasticsearch:
+``` yaml
+ibexa_elasticsearch:
# ...
index_templates:
default_en_us:
patterns: ['default_*', '*eng_us*']
- # ...
- ```
+ # ...
+```
- `settings` - Settings under this key control all aspects related to an index.
@@ -392,21 +392,21 @@ For more information and a list of available settings, see [Elasticsearch docume
For example, you can define settings that convert text into a format that is optimized for search, like a normalizer that changes a case of all phrases in the index:
- ``` yaml
- ibexa_elasticsearch:
- # ...
- index_templates:
- default:
- # ...
- settings:
- analysis:
- normalizer:
- lowercase_normalizer:
- type: custom
- char_filter: []
- filter: lowercase
- # ...
- ```
+``` yaml
+ibexa_elasticsearch:
+ # ...
+ index_templates:
+ default:
+ # ...
+ settings:
+ analysis:
+ normalizer:
+ lowercase_normalizer:
+ type: custom
+ char_filter: []
+ filter: lowercase
+ # ...
+```
- `mappings` - Settings under this key define mapping for fields in the index.
diff --git a/docs/search/search_engines/solr_search_engine/install_solr.md b/docs/search/search_engines/solr_search_engine/install_solr.md
index edf2a76c0c..b7de523435 100644
--- a/docs/search/search_engines/solr_search_engine/install_solr.md
+++ b/docs/search/search_engines/solr_search_engine/install_solr.md
@@ -160,9 +160,9 @@ The Solr Search Engine Bundle can be configured in many ways.
The config further below assumes you have parameters set up for Solr DSN and search engine *(however both are optional)*, for example:
``` yaml
- env(SEARCH_ENGINE): solr
- env(SOLR_DSN): 'http://localhost:8983/solr'
- env(SOLR_CORE): collection1
+env(SEARCH_ENGINE): solr
+env(SOLR_DSN): 'http://localhost:8983/solr'
+env(SOLR_CORE): collection1
```
### Configure Solr version
diff --git a/docs/templating/image_variations.md b/docs/templating/image_variations.md
index cdcb8d9046..e4718dadfb 100644
--- a/docs/templating/image_variations.md
+++ b/docs/templating/image_variations.md
@@ -26,7 +26,9 @@ ibexa:
:
reference: null
filters:
- :
+ filter_name:
+ - parameter1
+ - parameter2
```
Variation name must be unique.
diff --git a/docs/templating/templates/view_matcher_reference.md b/docs/templating/templates/view_matcher_reference.md
index 687569c488..4e706fbeb1 100644
--- a/docs/templating/templates/view_matcher_reference.md
+++ b/docs/templating/templates/view_matcher_reference.md
@@ -235,7 +235,7 @@ match:
``` yaml
match:
- '@Ibexa\Taxonomy\View\Matcher\TaxonomyEntryBased\Id': [1, 2, 3]'
+ '@Ibexa\Taxonomy\View\Matcher\TaxonomyEntryBased\Id': [1, 2, 3]
```
## Taxonomy entry identifier
@@ -264,4 +264,4 @@ match:
``` yaml
match:
'@Ibexa\Taxonomy\View\Matcher\TaxonomyEntryBased\Taxonomy': 'product_category'
-```
\ No newline at end of file
+```
diff --git a/docs/users/invitations.md b/docs/users/invitations.md
index 446e852902..ddcebdd860 100644
--- a/docs/users/invitations.md
+++ b/docs/users/invitations.md
@@ -30,13 +30,13 @@ If the SiteAccess isn't set, it falls back to the default `site` value.
For example, use the following [configuration](configuration.md#configuration-files):
```yaml
- ibexa:
- system:
- :
- user_invitation:
- hash_expiration_time: P7D
- templates:
- mail: "@@App/invitation/mail.html.twig"
+ibexa:
+ system:
+ :
+ user_invitation:
+ hash_expiration_time: P7D
+ templates:
+ mail: "@@App/invitation/mail.html.twig"
```
Here, you can specify which template should be used for the invitation mail, and what should be the expiration time for the invitation link included in that mail.
diff --git a/docs/users/oauth_server.md b/docs/users/oauth_server.md
index 45ac506920..bc12a9889f 100644
--- a/docs/users/oauth_server.md
+++ b/docs/users/oauth_server.md
@@ -89,7 +89,7 @@ In `config/packages/security.yaml`, uncomment the three following lines under th
```yaml
security:
#…
- firewall:
+ firewalls:
#…
# Uncomment oauth2_token firewall if you wish to use product as an OAuth2 Server.
diff --git a/docs/users/user_authentication.md b/docs/users/user_authentication.md
index d2b7280301..9ec9e79c5d 100644
--- a/docs/users/user_authentication.md
+++ b/docs/users/user_authentication.md
@@ -66,7 +66,7 @@ services:
App\EventListener\InteractiveLoginListener:
arguments: ['@ibexa.api.service.user']
tags:
- - { name: kernel.event_subscriber }
+ - { name: kernel.event_subscriber }
```
Don't mix `MVCEvents::INTERACTIVE_LOGIN` event (specific to [[= product_name =]]) and `SecurityEvents::INTERACTIVE_LOGIN` event (fired by Symfony security component).
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000000..e084420e57
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,14 @@
+
+
+
+
+ tests/
+
+
+
diff --git a/tests/ConfigurationProvider.php b/tests/ConfigurationProvider.php
new file mode 100644
index 0000000000..4dfbe49523
--- /dev/null
+++ b/tests/ConfigurationProvider.php
@@ -0,0 +1,178 @@
+container = $this->buildContainer();
+ }
+
+ public function hasExtension(string $alias): bool
+ {
+ return $this->container->hasExtension($alias);
+ }
+
+ public function createConfiguration(string $alias): ConfigurationInterface
+ {
+ return $this->container->getExtension($alias)->getConfiguration([], $this->container);
+ }
+
+ /**
+ * Recursively resolves %parameter% placeholders using the container's
+ * parameter bag, mirroring what the real Symfony kernel does before
+ * passing config to the Config component. Unknown parameters (custom app
+ * params not present in the test container) are left as-is.
+ *
+ * @param array $config
+ *
+ * @return array
+ */
+ public function resolveParameters(array $config): array
+ {
+ /** @var array $result */
+ $result = $this->resolveValue($this->container->getParameterBag(), $config);
+
+ return $result;
+ }
+
+ private function resolveValue(ParameterBagInterface $bag, mixed $value): mixed
+ {
+ if (is_array($value)) {
+ return array_map(fn (mixed $v): mixed => $this->resolveValue($bag, $v), $value);
+ }
+
+ if (!is_string($value)) {
+ return $value;
+ }
+
+ try {
+ return $bag->resolveValue($value);
+ } catch (ParameterNotFoundException) {
+ return $value;
+ }
+ }
+
+ private function buildContainer(): ContainerBuilder
+ {
+ $container = new ContainerBuilder();
+ $container->setParameter('kernel.debug', false);
+ $container->setParameter('kernel.bundles', []);
+ $container->setParameter('kernel.bundles_metadata', []);
+ $container->setParameter('kernel.project_dir', sys_get_temp_dir());
+ $container->setParameter('kernel.environment', 'test');
+
+ $bundles = self::discoverBundles();
+
+ // Register all extensions before calling build() on any bundle,
+ // because some bundles call $container->getExtension('ibexa') during build().
+ foreach ($bundles as $bundle) {
+ try {
+ $extension = $bundle->getContainerExtension();
+ if ($extension !== null) {
+ $container->registerExtension($extension);
+ }
+ } catch (\Throwable) {
+ // Skip bundles whose extension cannot be instantiated.
+ }
+ }
+
+ // build() registers parsers/factories into the extensions.
+ foreach ($bundles as $bundle) {
+ try {
+ $bundle->build($container);
+ } catch (\Throwable) {
+ // Skip bundles whose build() fails (e.g. missing sibling extensions).
+ }
+ }
+
+ return $container;
+ }
+
+ /**
+ * Returns all installed bundles with SecurityBundle and IbexaCoreBundle
+ * guaranteed first (other bundles may call getExtension('ibexa') or
+ * getExtension('security') during their build()).
+ *
+ * @return list
+ */
+ private static function discoverBundles(): array
+ {
+ // These must be registered before any bundle that calls
+ // $container->getExtension('ibexa'/'security') inside build().
+ $bundles = [
+ new SecurityBundle(),
+ new IbexaCoreBundle(),
+ ];
+
+ $seen = [SecurityBundle::class, IbexaCoreBundle::class];
+
+ $vendorBase = __DIR__ . '/../vendor';
+ $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($vendorBase));
+
+ foreach ($iterator as $file) {
+ if (!$file->isFile() || !preg_match('/\w+Bundle\.php$/', $file->getFilename())) {
+ continue;
+ }
+
+ $content = file_get_contents($file->getPathname());
+ preg_match('/^namespace (.+);/m', $content, $nsMatch);
+ preg_match('/^(?:(?:final|abstract)\s+)?class (\w+Bundle)\b/m', $content, $clsMatch);
+
+ if (empty($nsMatch[1]) || empty($clsMatch[1])) {
+ continue;
+ }
+
+ $fqcn = $nsMatch[1] . '\\' . $clsMatch[1];
+
+ if (!class_exists($fqcn) || in_array($fqcn, $seen, true)) {
+ continue;
+ }
+
+ $reflection = new \ReflectionClass($fqcn);
+ if ($reflection->isAbstract() || !$reflection->implementsInterface(BundleInterface::class)) {
+ continue;
+ }
+
+ $seen[] = $fqcn;
+
+ try {
+ $bundles[] = new $fqcn();
+ } catch (\Throwable) {
+ // Skip bundles that cannot be instantiated without arguments.
+ }
+ }
+
+ return $bundles;
+ }
+}
diff --git a/tests/Markdown/MarkdownYamlExtractor.php b/tests/Markdown/MarkdownYamlExtractor.php
new file mode 100644
index 0000000000..a78f95385d
--- /dev/null
+++ b/tests/Markdown/MarkdownYamlExtractor.php
@@ -0,0 +1,78 @@
+ *)```\s*yaml[^\n]*\n(?P.*?)\n(?P=indent)```/ms';
+
+ private const string SKIP_PATTERN = '/include_file\s*\(|--8<--/';
+
+ /**
+ * @return iterable
+ */
+ public function extract(string $content): iterable
+ {
+ if (!preg_match_all(self::FENCE_PATTERN, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
+ return;
+ }
+
+ foreach ($matches as $match) {
+ $body = $match['body'][0];
+ $offset = $match['body'][1];
+
+ if (preg_match(self::SKIP_PATTERN, $body)) {
+ continue;
+ }
+
+ $indent = $match['indent'][0];
+ if ($indent !== '') {
+ $body = $this->stripIndentation($body, strlen($indent));
+ }
+
+ $line = substr_count(substr($content, 0, $offset), "\n") + 1;
+
+ yield ['body' => $body, 'line' => $line];
+ }
+ }
+
+ private function stripIndentation(string $body, int $spaces): string
+ {
+ $prefix = str_repeat(' ', $spaces);
+ $lines = explode("\n", $body);
+ $stripped = array_map(
+ static fn (string $line): string => str_starts_with($line, $prefix)
+ ? substr($line, $spaces)
+ : $line,
+ $lines
+ );
+
+ return implode("\n", $stripped);
+ }
+}
diff --git a/tests/Markdown/MarkdownYamlExtractorTest.php b/tests/Markdown/MarkdownYamlExtractorTest.php
new file mode 100644
index 0000000000..b65c6445a8
--- /dev/null
+++ b/tests/Markdown/MarkdownYamlExtractorTest.php
@@ -0,0 +1,208 @@
+extractor = new MarkdownYamlExtractor();
+ }
+
+ public function testExtractsNothing(): void
+ {
+ self::assertEmpty(iterator_to_array($this->extractor->extract('No code blocks here.')));
+ self::assertEmpty(iterator_to_array($this->extractor->extract('')));
+ }
+
+ public function testIgnoresNonYamlFences(): void
+ {
+ $content = <<<'MD'
+ ```php
+ $x = 1;
+ ```
+
+ ```json
+ {"key": "value"}
+ ```
+ MD;
+
+ self::assertEmpty(iterator_to_array($this->extractor->extract($content)));
+ }
+
+ public function testExtractsSingleBlock(): void
+ {
+ $content = <<<'MD'
+ Some text.
+
+ ```yaml
+ foo: bar
+ ```
+
+ More text.
+ MD;
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ self::assertSame('foo: bar', $blocks[0]['body']);
+ }
+
+ public function testExtractsMultipleBlocks(): void
+ {
+ $content = <<<'MD'
+ ```yaml
+ first: 1
+ ```
+
+ ```yaml
+ second: 2
+ ```
+ MD;
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(2, $blocks);
+ self::assertSame('first: 1', $blocks[0]['body']);
+ self::assertSame('second: 2', $blocks[1]['body']);
+ }
+
+ public function testReportsCorrectLineNumber(): void
+ {
+ $content = "line1\nline2\nline3\n```yaml\nfoo: bar\n```\n";
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ // The body starts on line 5 (after 4 preceding newlines inside the fence open)
+ self::assertSame(5, $blocks[0]['line']);
+ }
+
+ public function testAcceptsSpaceBeforeLanguageTag(): void
+ {
+ $content = "``` yaml\nfoo: bar\n```\n";
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ self::assertSame('foo: bar', $blocks[0]['body']);
+ }
+
+ public function testAcceptsTrailingAnnotations(): void
+ {
+ $content = "```yaml hl_lines=\"1 2\"\nfoo: bar\n```\n";
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ self::assertSame('foo: bar', $blocks[0]['body']);
+ }
+
+ public function testStripsAdmonitionIndentation(): void
+ {
+ $content = <<<'MD'
+ !!! note
+
+ ```yaml
+ foo: bar
+ baz: qux
+ ```
+ MD;
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ self::assertSame("foo: bar\nbaz: qux", $blocks[0]['body']);
+ }
+
+ public function testSkipsBlocksWithIncludeFile(): void
+ {
+ $content = <<<'MD'
+ ```yaml
+ [[= include_file('some/file.yaml') =]]
+ ```
+ MD;
+
+ self::assertEmpty(iterator_to_array($this->extractor->extract($content)));
+ }
+
+ public function testSkipsBlocksWithSnippetMarker(): void
+ {
+ $content = <<<'MD'
+ ```yaml
+ --8<--
+ some/file.yaml
+ ```
+ MD;
+
+ self::assertEmpty(iterator_to_array($this->extractor->extract($content)));
+ }
+
+ public function testSkipsOnlyMatchingBlocksWhenMixed(): void
+ {
+ $content = <<<'MD'
+ ```yaml
+ [[= include_file('foo.yaml') =]]
+ ```
+
+ ```yaml
+ real: config
+ ```
+ MD;
+
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(1, $blocks);
+ self::assertSame('real: config', $blocks[0]['body']);
+ }
+
+ /**
+ * @param array $expected
+ */
+ #[DataProvider('provideMultilineBlocks')]
+ public function testExtractsMultilineBody(string $content, array $expected): void
+ {
+ $blocks = iterator_to_array($this->extractor->extract($content));
+
+ self::assertCount(count($expected), $blocks);
+ foreach ($expected as $i => $exp) {
+ self::assertSame($exp['body'], $blocks[$i]['body'], "body at index $i");
+ self::assertSame($exp['line'], $blocks[$i]['line'], "line at index $i");
+ }
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function provideMultilineBlocks(): iterable
+ {
+ yield 'nested mapping' => [
+ "```yaml\nparent:\n child: value\n```\n",
+ [['body' => "parent:\n child: value", 'line' => 2]],
+ ];
+
+ yield 'sequence' => [
+ "```yaml\nlist:\n - a\n - b\n```\n",
+ [['body' => "list:\n - a\n - b", 'line' => 2]],
+ ];
+
+ yield 'two blocks with correct lines' => [
+ "```yaml\nfoo: 1\n```\n\nsome text\n\n```yaml\nbar: 2\n```\n",
+ [
+ ['body' => 'foo: 1', 'line' => 2],
+ ['body' => 'bar: 2', 'line' => 8],
+ ],
+ ];
+ }
+}
diff --git a/tests/ValidationBaseline.php b/tests/ValidationBaseline.php
new file mode 100644
index 0000000000..d689915b2f
--- /dev/null
+++ b/tests/ValidationBaseline.php
@@ -0,0 +1,74 @@
+|null */
+ private ?array $entries = null;
+
+ public function __construct(
+ private readonly string $baselineFile,
+ private readonly string $repoRoot,
+ ) {
+ }
+
+ public function isInBaseline(string $relativePath, ?int $line, string $errorMessage): bool
+ {
+ foreach ($this->getEntries() as $entry) {
+ $entryPath = $entry['path'] ?? '';
+
+ // Path: exact match or trailing-suffix match (allows glob-like partial paths)
+ if ($relativePath !== $entryPath && !str_ends_with($relativePath, ltrim($entryPath, '/'))) {
+ continue;
+ }
+
+ // Line (optional): must match exactly when provided
+ if (isset($entry['line']) && $line !== null && (int) $entry['line'] !== $line) {
+ continue;
+ }
+
+ // Message (optional): treated as a regex pattern
+ if (isset($entry['message']) && !preg_match($entry['message'], $errorMessage)) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return list
+ */
+ private function getEntries(): array
+ {
+ if ($this->entries !== null) {
+ return $this->entries;
+ }
+
+ if (!file_exists($this->baselineFile)) {
+ return $this->entries = [];
+ }
+
+ $parsed = \Symfony\Component\Yaml\Yaml::parseFile($this->baselineFile);
+
+ return $this->entries = $parsed['ignoreErrors'] ?? [];
+ }
+}
diff --git a/tests/Yaml/CodeSample.php b/tests/Yaml/CodeSample.php
new file mode 100644
index 0000000000..7429e6feeb
--- /dev/null
+++ b/tests/Yaml/CodeSample.php
@@ -0,0 +1,13 @@
+
+ */
+ public function getCodeSampleYaml(): iterable
+ {
+ yield from $this->iterateCodeSampleYaml();
+ yield from $this->iterateMarkdownYamlBlocks();
+ }
+
+ /**
+ * Yields every .yaml file found recursively under code_samples/.
+ *
+ * @return iterable
+ */
+ private function iterateCodeSampleYaml(): iterable
+ {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(self::CODE_SAMPLES_DIR, RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ /** @var \SplFileInfo $file */
+ foreach ($iterator as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'yaml') {
+ continue;
+ }
+
+ $body = file_get_contents($file->getRealPath());
+
+ if ($body === false) {
+ continue;
+ }
+
+ yield new CodeSample($file->getRealPath(), 0, $body);
+ }
+ }
+
+ /**
+ * Yields every fenced YAML block found in .md files under docs/.
+ *
+ * @return iterable
+ */
+ private function iterateMarkdownYamlBlocks(): iterable
+ {
+ $extractor = new MarkdownYamlExtractor();
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(self::DOCS_DIR, RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ /** @var \SplFileInfo $file */
+ foreach ($iterator as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'md') {
+ continue;
+ }
+
+ $path = $file->getRealPath();
+ $content = file_get_contents($path);
+
+ if ($content === false) {
+ continue;
+ }
+
+ foreach ($extractor->extract($content) as $block) {
+ yield new CodeSample($path, $block['line'], $block['body']);
+ }
+ }
+ }
+}
diff --git a/tests/Yaml/YamlTest.php b/tests/Yaml/YamlTest.php
new file mode 100644
index 0000000000..6cf2824e60
--- /dev/null
+++ b/tests/Yaml/YamlTest.php
@@ -0,0 +1,165 @@
+isInBaseline($filePath, $line ?: null, $e->getMessage())) {
+ self::markTestSkipped(sprintf(
+ 'Known baseline issue in %s at line %d: %s',
+ $filePath,
+ $line,
+ $e->getMessage(),
+ ));
+ }
+
+ self::fail(sprintf(
+ 'YAML parse error in %s at line %d: %s',
+ $filePath,
+ $line,
+ $e->getMessage(),
+ ));
+ }
+
+ $this->addToAssertionCount(1);
+ }
+
+ /**
+ * @param int $line Starting line of the config block (0 for standalone YAML files).
+ */
+ #[DataProvider('provideBundleConfigs')]
+ public function testBundleConfigurationIsValid(
+ string $extensionName,
+ mixed $config,
+ string $filePath,
+ int $line
+ ): void {
+ $configuration = self::configurationProvider()->createConfiguration($extensionName);
+ $processor = new Processor();
+
+ $config = self::configurationProvider()->resolveParameters(is_array($config) ? $config : []);
+
+ try {
+ $processor->processConfiguration($configuration, [$config]);
+ } catch (\Exception $e) {
+ if (self::baseline()->isInBaseline($filePath, $line ?: null, $e->getMessage())) {
+ self::markTestSkipped(sprintf(
+ 'Known baseline issue for "%s" in %s:%d: %s',
+ $extensionName,
+ $filePath,
+ $line,
+ $e->getMessage(),
+ ));
+ }
+
+ self::fail(sprintf(
+ 'Invalid configuration for "%s" in %s:%d — %s',
+ $extensionName,
+ $filePath,
+ $line,
+ $e->getMessage(),
+ ));
+ }
+
+ $this->addToAssertionCount(1);
+ }
+
+ /**
+ * Yields all standalone YAML files from code_samples/ plus every fenced
+ * YAML block extracted from docs Markdown files.
+ *
+ * @return iterable
+ */
+ public static function provideYamlSources(): iterable
+ {
+ foreach (self::samplesProvider()->getCodeSampleYaml() as $item) {
+ yield self::makeLabel($item->path, $item->line) => [$item->path, $item->line, $item->body];
+ }
+ }
+
+ /**
+ * Yields one entry per (extension, config) pair found in YAML files and
+ * in fenced YAML blocks from docs Markdown files.
+ *
+ * @return iterable
+ */
+ public static function provideBundleConfigs(): iterable
+ {
+ foreach (self::provideYamlSources() as [$filePath, $line, $body]) {
+ $path = self::relativePath($filePath);
+ try {
+ $parsed = Yaml::parse($body, Yaml::PARSE_CUSTOM_TAGS);
+ } catch (\Throwable) {
+ continue;
+ }
+
+ if (!is_array($parsed)) {
+ continue;
+ }
+
+ foreach ($parsed as $extensionName => $config) {
+ if (!is_string($extensionName) || !self::configurationProvider()->hasExtension($extensionName)) {
+ continue;
+ }
+
+ yield sprintf('%s (%s)', $extensionName, self::makeLabel($path, $line)) => [$extensionName, $config, $path, $line];
+ }
+ }
+ }
+
+ private static function configurationProvider(): ConfigurationProvider
+ {
+ static $provider = null;
+
+ return $provider ??= new ConfigurationProvider();
+ }
+
+ private static function samplesProvider(): YamlSamplesProvider
+ {
+ static $provider = null;
+
+ return $provider ??= new YamlSamplesProvider();
+ }
+
+ private static function baseline(): ValidationBaseline
+ {
+ static $baseline = null;
+
+ return $baseline ??= new ValidationBaseline(self::BASELINE_FILE, realpath(self::REPO_ROOT));
+ }
+
+ private static function makeLabel(string $absolutePath, int $lineNumber): string
+ {
+ return ltrim(str_replace(realpath(self::REPO_ROOT), '', $absolutePath), '/') . ':' . $lineNumber;
+ }
+
+ private static function relativePath(string $absolutePath): string
+ {
+ return ltrim(str_replace(realpath(self::REPO_ROOT), '', $absolutePath), '/');
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000000..9374e6f3a2
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,40 @@
+isDir() && $item->getFilename() === 'src') {
+ $loader->addPsr4('App\\', $item->getRealPath());
+ }
+}
diff --git a/tests/generate-yaml-baseline.php b/tests/generate-yaml-baseline.php
new file mode 100644
index 0000000000..d8a6f7d7ff
--- /dev/null
+++ b/tests/generate-yaml-baseline.php
@@ -0,0 +1,142 @@
+#!/usr/bin/env php
+/dev/null',
+ escapeshellarg($phpunitBin),
+ escapeshellarg($tmpLog),
+);
+
+passthru($cmd);
+
+if (!file_exists($tmpLog)) {
+ if ($previousBaseline !== null) {
+ file_put_contents($outFile, $previousBaseline);
+ }
+ fwrite(STDERR, "ERROR: PHPUnit did not produce a JUnit log file.\n");
+ exit(1);
+}
+
+$xml = simplexml_load_file($tmpLog);
+unlink($tmpLog);
+
+if ($xml === false) {
+ fwrite(STDERR, "ERROR: Could not parse JUnit XML.\n");
+ exit(1);
+}
+
+$repoRoot = dirname(__DIR__);
+$entries = [];
+
+// Collect all failures/errors at any nesting depth using XPath
+/** @var \SimpleXMLElement[] $testcases */
+$testcases = $xml->xpath('//testcase[failure or error]') ?: [];
+
+foreach ($testcases as $testcase) {
+ $failure = $testcase->failure ?? $testcase->error ?? null;
+ if ($failure === null) {
+ continue;
+ }
+
+ $message = (string) $failure;
+ // Extract file path and line from failure message
+ // Patterns:
+ // "Invalid configuration for "X" in path/to/file.yaml: error"
+ // "Invalid configuration for "X" in path/to/file.md:123 — error"
+ // "YAML parse error in path/to/file.md at line 123: error"
+ $path = null;
+ $line = null;
+ $errorMessage = null;
+
+ if (preg_match('/Invalid configuration for "[^"]*" in ([^\n:]+?):(\d+) — (.+)/s', $message, $m)) {
+ $path = trim($m[1]);
+ $line = (int) $m[2];
+ $errorMessage = trim(explode("\n", $m[3])[0]);
+ } elseif (preg_match('/Invalid configuration for "[^"]*" in ([^\n:]+?): (.+)/s', $message, $m)) {
+ $path = trim($m[1]);
+ $errorMessage = trim(explode("\n", $m[2])[0]);
+ } elseif (preg_match('/YAML parse error in ([^\n]+?) at line (\d+): (.+)/s', $message, $m)) {
+ $path = trim($m[1]);
+ $line = (int) $m[2];
+ $errorMessage = trim(explode("\n", $m[3])[0]);
+ }
+
+ if ($path === null) {
+ continue;
+ }
+
+ // Convert absolute path to relative
+ if (str_starts_with($path, $repoRoot)) {
+ $path = ltrim(substr($path, strlen($repoRoot)), '/');
+ }
+
+ $entry = ['path' => $path];
+ if ($line !== null) {
+ $entry['line'] = $line;
+ }
+ if ($errorMessage !== null) {
+ // Store as a regex: escape special chars, keep it readable
+ $entry['message'] = '~' . preg_quote($errorMessage, '~') . '~';
+ }
+
+ $key = $path . ':' . ($line ?? '');
+ $entries[$key] = $entry;
+}
+
+ksort($entries);
+
+// Render as YAML manually (keep it readable without needing a YAML library)
+$lines = [];
+$lines[] = '# Auto-generated by `composer phpunit-update-baseline`. Do not edit manually.';
+$lines[] = '# To suppress a failure: regenerate this file after confirming it is expected.';
+$lines[] = '# To fix a suppressed failure: fix the doc error and regenerate.';
+$lines[] = 'ignoreErrors:';
+
+foreach ($entries as $entry) {
+ $lines[] = ' -';
+ $lines[] = sprintf(' path: %s', $entry['path']);
+ if (isset($entry['line'])) {
+ $lines[] = sprintf(' line: %d', $entry['line']);
+ }
+ if (isset($entry['message'])) {
+ // Wrap message in single quotes, escaping internal single quotes
+ $msg = str_replace("'", "''", $entry['message']);
+ $lines[] = sprintf(" message: '%s'", $msg);
+ }
+}
+
+$content = implode("\n", $lines) . "\n";
+$outFile = __DIR__ . '/yaml-validation-baseline.yaml';
+file_put_contents($outFile, $content);
+
+$count = count($entries);
+echo "Baseline written to tests/yaml-validation-baseline.yaml ({$count} entries)\n";
+if ($count === 0) {
+ echo "No failures found — baseline is empty. All tests pass!\n";
+}
diff --git a/tests/yaml-validation-baseline.yaml b/tests/yaml-validation-baseline.yaml
new file mode 100644
index 0000000000..01e5acfe11
--- /dev/null
+++ b/tests/yaml-validation-baseline.yaml
@@ -0,0 +1,316 @@
+# Auto-generated by `composer phpunit-update-baseline`. Do not edit manually.
+# To suppress a failure: regenerate this file after confirming it is expected.
+# To fix a suppressed failure: fix the doc error and regenerate.
+ignoreErrors:
+ -
+ path: code_samples/forms/custom_form_attribute/config/custom_services.yaml
+ line: 0
+ message: '~Unable to parse at line 1 \(near " App\\FormBuilder\\FieldType\\Field\\Mapper\\CheckboxWithRichtextDescriptionFieldMapper\:"\)\.~'
+ -
+ path: code_samples/workflow/custom_workflow/config/packages/workflows.yaml
+ line: 0
+ message: '~The child config "stages" under "ibexa\.system\.default\.workflows\.quick_review" must be configured\.~'
+ -
+ path: docs/administration/back_office/back_office_elements/extending_thumbnails.md
+ line: 109
+ message: '~Unable to parse at line 1 \(near " App\\Thumbnails\\FieldValueUrl\:"\)\.~'
+ -
+ path: docs/administration/back_office/configure_product_tour.md
+ line: 26
+ message: '~The value "\" is not allowed for path "ibexa\.system\.\\>\.product_tour\.\\.type"\. Permissible values\: "general", "targetable"\.~'
+ -
+ path: docs/cdp/cdp_data_customization.md
+ line: 29
+ message: '~Unable to parse at line 1 \(near " App\\Export\\User\\DateOfBirthUserItemProcessor\:"\)\.~'
+ -
+ path: docs/commerce/checkout/customize_checkout.md
+ line: 130
+ message: '~Invalid configuration for path "framework\.workflows\.workflows\.ibexa_checkout"\: "supports" or "support_strategy" should be configured\.~'
+ -
+ path: docs/commerce/order_management/configure_order_management.md
+ line: 51
+ message: '~The child config "transitions" under "framework\.workflows\.workflows\.ibexa_order" must be configured\.~'
+ -
+ path: docs/commerce/payment/enable_paypal_payments.md
+ line: 40
+ message: '~Unrecognized option "payment_method" under "ibexa"\. Available options are "http_cache", "image_placeholder", "imagemagick", "locale_conversion", "orm", "repositories", "router", "siteaccess", "system", "ui", "url_alias", "url_wildcards"\.~'
+ -
+ path: docs/commerce/payment/enable_stripe_payments.md
+ line: 41
+ message: '~Unrecognized option "payment_method" under "ibexa"\. Available options are "http_cache", "image_placeholder", "imagemagick", "locale_conversion", "orm", "repositories", "router", "siteaccess", "system", "ui", "url_alias", "url_wildcards"\.~'
+ -
+ path: docs/commerce/payment/payum_integration.md
+ line: 55
+ message: '~Unrecognized option "payment_method" under "ibexa"\. Available options are "http_cache", "image_placeholder", "imagemagick", "locale_conversion", "orm", "repositories", "router", "siteaccess", "system", "ui", "url_alias", "url_wildcards"\.~'
+ -
+ path: docs/commerce/transactional_emails/extend_transactional_emails.md
+ line: 17
+ message: '~The child config "transitions" under "framework\.workflows\.workflows\.ibexa_payment" must be configured\.~'
+ -
+ path: docs/content_management/collaborative_editing/configure_collaborative_editing.md
+ line: 48
+ message: '~The child config "firewalls" under "security" must be configured\.~'
+ -
+ path: docs/content_management/collaborative_editing/configure_collaborative_editing.md
+ line: 78
+ message: '~Invalid type for path "ibexa\.repositories\.\\.collaboration\.participants\.auto_invite"\. Expected "bool", but got "string"\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 102
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 114
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 136
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 156
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 178
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 78
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 86
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/data_migration_actions.md
+ line: 96
+ message: '~Unable to parse at line 1 \(near " actions\:"\)\.~'
+ -
+ path: docs/content_management/data_migration/importing_data.md
+ line: 200
+ message: '~Unable to parse at line 1 \(near " \- fieldDefIdentifier\: show_children"\)\.~'
+ -
+ path: docs/content_management/data_migration/importing_data.md
+ line: 220
+ message: '~Unable to parse at line 1 \(near " \- fieldDefIdentifier\: some_field"\)\.~'
+ -
+ path: docs/content_management/data_migration/importing_data.md
+ line: 228
+ message: '~Unable to parse at line 1 \(near " \- fieldDefIdentifier\: project_directory"\)\.~'
+ -
+ path: docs/content_management/data_migration/importing_data.md
+ line: 313
+ message: '~Unable to parse at line 1 \(near " \- fieldDefIdentifier\: image"\)\.~'
+ -
+ path: docs/content_management/taxonomy/taxonomy.md
+ line: 194
+ message: '~The child config "default_embedding_max_tokens" under "ibexa_taxonomy\.text_to_taxonomy" must be configured\: Maximum number of tokens sent when generating embeddings~'
+ -
+ path: docs/content_management/workflow/workflow.md
+ line: 135
+ message: '~The child config "matcher_value_templates" under "ibexa\.system\.default\.workflows_config" must be configured\: Matcher templates configuration\.~'
+ -
+ path: docs/customer_management/cp_page_builder.md
+ line: 119
+ message: '~The child config "default_siteaccess" under "ibexa\.siteaccess" must be configured\: Name of the default siteaccess~'
+ -
+ path: docs/customer_management/cp_page_builder.md
+ line: 207
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/customer_management/cp_page_builder.md
+ line: 35
+ message: '~The child config "default_siteaccess" under "ibexa\.siteaccess" must be configured\: Name of the default siteaccess~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 139
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\Condition\\IsAccountAnniversaryConditionFactory\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 187
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\Rule\\PurchasingPowerParityRuleFactory\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 211
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\Rule\\PurchaseParityValueFormatter\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 229
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\RecentDiscountPrioritizationStrategy\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 64
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\ExpressionProvider\\CurrentUserRegistrationDateResolver\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts.md
+ line: 82
+ message: '~Unable to parse at line 1 \(near " App\\Discounts\\ExpressionProvider\\IsAnniversaryResolver\:"\)\.~'
+ -
+ path: docs/discounts/extend_discounts_wizard.md
+ line: 142
+ message: '~Unable to parse at line 1 \(near " App\\Form\\FormMapper\\PurchasingPowerParityValueMapper\: \~"\)\.~'
+ -
+ path: docs/getting_started/first_steps.md
+ line: 115
+ message: '~The child config "default_siteaccess" under "ibexa\.siteaccess" must be configured\: Name of the default siteaccess~'
+ -
+ path: docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md
+ line: 128
+ message: '~The child config "fastly" under "ibexa\.system\.my_siteaccess_group\.http_cache" must be configured\.~'
+ -
+ path: docs/infrastructure_and_maintenance/cache/http_cache/reverse_proxy.md
+ line: 145
+ message: '~The child config "fastly" under "ibexa\.system\.my_siteaccess_group\.http_cache" must be configured\.~'
+ -
+ path: docs/infrastructure_and_maintenance/security/development_security.md
+ line: 20
+ message: '~Unrecognized option "require_previous_session" under "security\.firewalls\.ibexa_front\.form_login"\. Available options are "always_use_default_target_path", "check_path", "csrf_parameter", "csrf_token_id", "default_target_path", "enable_csrf", "failure_forward", "failure_handler", "failure_path", "failure_path_parameter", "form_only", "login_path", "password_parameter", "post_only", "provider", "remember_me", "success_handler", "target_path_parameter", "use_forward", "use_referer", "username_parameter"\.~'
+ -
+ path: docs/infrastructure_and_maintenance/security/security_checklist.md
+ line: 142
+ message: '~The child config "firewalls" under "security" must be configured\.~'
+ -
+ path: docs/multisite/languages/languages.md
+ line: 135
+ message: '~The child config "match" under "ibexa\.siteaccess" must be configured\: Siteaccess match configuration\. First key is the matcher class, value is passed to the matcher\. Key can be a service identifier \(prepended by "@"\), or a FQ class name \(prepended by "\\"\)~'
+ -
+ path: docs/multisite/languages/languages.md
+ line: 78
+ message: '~The child config "match" under "ibexa\.siteaccess" must be configured\: Siteaccess match configuration\. First key is the matcher class, value is passed to the matcher\. Key can be a service identifier \(prepended by "@"\), or a FQ class name \(prepended by "\\"\)~'
+ -
+ path: docs/multisite/multisite_configuration.md
+ line: 28
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/multisite_configuration.md
+ line: 57
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/multisite_configuration.md
+ line: 69
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/set_up_translation_siteaccess.md
+ line: 43
+ message: '~The child config "default_siteaccess" under "ibexa\.siteaccess" must be configured\: Name of the default siteaccess~'
+ -
+ path: docs/multisite/site_factory/site_factory.md
+ line: 40
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/site_factory/site_factory.md
+ line: 58
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/site_factory/site_factory_configuration.md
+ line: 81
+ message: '~The child config "siteaccess_group" under "ibexa_site_factory\.templates\.\" must be configured\.~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 108
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 123
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 140
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 161
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 178
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 194
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 237
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 32
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 72
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/multisite/siteaccess/siteaccess_matching.md
+ line: 91
+ message: '~The child config "list" under "ibexa\.siteaccess" must be configured\: Available SiteAccess list~'
+ -
+ path: docs/permissions/limitation_reference.md
+ line: 28
+ message: '~Unable to parse at line 3 \(near " ibexa\.api\.role\.limitation_type\.function_list\:"\)\.~'
+ -
+ path: docs/personalization/attribute_search_in_elasticsearch.md
+ line: 16
+ message: '~Invalid output type in \{"\"\:\{"title"\:"\"\}\}\. Output type id should be type of int\.~'
+ -
+ path: docs/product_catalog/enable_purchasing_products.md
+ line: 106
+ message: '~Unable to parse at line 1 \(near " none\:"\)\.~'
+ -
+ path: docs/product_catalog/product_catalog_configuration.md
+ line: 77
+ message: '~Unable to parse at line 1 \(near " none\:"\)\.~'
+ -
+ path: docs/recommendations/raptor_integration/tracking_functions.md
+ line: 33
+ message: '~Duplicate key "connector_raptor" detected at line 6 \(near " tracking_type\: ''client'' \# Returns \