summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorDave Long <dave@longwaveconsulting.com>2024-05-07 10:28:58 +0100
committerDave Long <dave@longwaveconsulting.com>2024-05-07 10:28:58 +0100
commit74da82a3145a3de4a015faccb96ad2eb8f4caf68 (patch)
tree2ff915ca7fd613c2ae41d28380719270712d1023
parent4a4a78b5babeb3ef38bc2806b11594467651ffa3 (diff)
downloaddrupal-74da82a3145a3de4a015faccb96ad2eb8f4caf68.tar.gz
drupal-74da82a3145a3de4a015faccb96ad2eb8f4caf68.zip
Issue #3439923 by alexpott, longwave, thejimbirch, Wim Leers, phenaproxima, immaculatexavier, nedjo, bircher, deviantintegral, franz, narendraR, omkar.podey, srishtiiee, Rajab Natshah, millnut, mondrake, amateescu, larowlan, sonfd, tasc, vasike: Add recipes api as experimental API to core
-rw-r--r--core/core.services.yml19
-rw-r--r--core/includes/install.core.inc91
-rw-r--r--core/lib/Drupal/Core/Command/InstallCommand.php86
-rw-r--r--core/lib/Drupal/Core/Command/QuickStartCommand.php7
-rw-r--r--core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php41
-rw-r--r--core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php57
-rw-r--r--core/lib/Drupal/Core/Config/Action/ConfigActionException.php12
-rw-r--r--core/lib/Drupal/Core/Config/Action/ConfigActionManager.php221
-rw-r--r--core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php25
-rw-r--r--core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php14
-rw-r--r--core/lib/Drupal/Core/Config/Action/EntityMethodException.php12
-rw-r--r--core/lib/Drupal/Core/Config/Action/Exists.php44
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php34
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php143
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php50
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php77
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php149
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php107
-rw-r--r--core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php64
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php35
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php14
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php88
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php494
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php48
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php144
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php20
-rw-r--r--core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php14
-rw-r--r--core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php3
-rw-r--r--core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php83
-rw-r--r--core/lib/Drupal/Core/DefaultContent/Existing.php18
-rw-r--r--core/lib/Drupal/Core/DefaultContent/Finder.php82
-rw-r--r--core/lib/Drupal/Core/DefaultContent/ImportException.php14
-rw-r--r--core/lib/Drupal/Core/DefaultContent/Importer.php378
-rw-r--r--core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php27
-rw-r--r--core/lib/Drupal/Core/Entity/EntityDisplayBase.php3
-rw-r--r--core/lib/Drupal/Core/Field/FieldConfigBase.php3
-rw-r--r--core/lib/Drupal/Core/Recipe/ConfigConfigurator.php123
-rw-r--r--core/lib/Drupal/Core/Recipe/InstallConfigurator.php120
-rw-r--r--core/lib/Drupal/Core/Recipe/InvalidConfigException.php60
-rw-r--r--core/lib/Drupal/Core/Recipe/Recipe.php301
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php28
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeCommand.php216
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php78
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php162
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeConfigurator.php56
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeDiscovery.php58
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php146
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeFileException.php59
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php39
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php137
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php31
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeRunner.php319
-rw-r--r--core/lib/Drupal/Core/Recipe/UnknownRecipeException.php31
-rw-r--r--core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php102
-rw-r--r--core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php127
-rw-r--r--core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml4
-rw-r--r--core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php24
-rw-r--r--core/modules/config/tests/config_test/config/schema/config_test.schema.yml3
-rw-r--r--core/modules/config/tests/config_test/config_test.module4
-rw-r--r--core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php17
-rw-r--r--core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php21
-rw-r--r--core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php9
-rw-r--r--core/modules/config/tests/config_test/src/Entity/ConfigTest.php148
-rw-r--r--core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php74
-rw-r--r--core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php59
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php110
-rw-r--r--core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php87
-rw-r--r--core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php148
-rw-r--r--core/modules/filter/src/Entity/FilterFormat.php3
-rw-r--r--core/modules/user/src/Entity/Role.php3
-rw-r--r--core/profiles/standard/tests/src/Functional/StandardTest.php299
-rw-r--r--core/profiles/standard/tests/src/Traits/StandardTestTrait.php318
-rw-r--r--core/recipes/article_comment/config/field.field.node.article.comment.yml32
-rw-r--r--core/recipes/article_comment/recipe.yml29
-rw-r--r--core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml87
-rw-r--r--core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml41
-rw-r--r--core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml21
-rw-r--r--core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml41
-rw-r--r--core/recipes/article_content_type/config/field.field.node.article.body.yml22
-rw-r--r--core/recipes/article_content_type/config/field.field.node.article.field_image.yml37
-rw-r--r--core/recipes/article_content_type/config/field.storage.node.field_image.yml31
-rw-r--r--core/recipes/article_content_type/config/node.type.article.yml10
-rw-r--r--core/recipes/article_content_type/recipe.yml28
-rw-r--r--core/recipes/article_tags/config/field.field.node.article.field_tags.yml26
-rw-r--r--core/recipes/article_tags/config/field.storage.node.field_tags.yml19
-rw-r--r--core/recipes/article_tags/recipe.yml39
-rw-r--r--core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml52
-rw-r--r--core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml19
-rw-r--r--core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml29
-rw-r--r--core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml31
-rw-r--r--core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml26
-rw-r--r--core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml22
-rw-r--r--core/recipes/audio_media_type/config/media.type.audio.yml13
-rw-r--r--core/recipes/audio_media_type/recipe.yml27
-rw-r--r--core/recipes/basic_block_type/config/block_content.type.basic.yml7
-rw-r--r--core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml32
-rw-r--r--core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml21
-rw-r--r--core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml22
-rw-r--r--core/recipes/basic_block_type/recipe.yml12
-rw-r--r--core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml65
-rw-r--r--core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml50
-rw-r--r--core/recipes/basic_html_format_editor/recipe.yml9
-rw-r--r--core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml18
-rw-r--r--core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml18
-rw-r--r--core/recipes/basic_shortcuts/recipe.yml12
-rw-r--r--core/recipes/comment_base/config/comment.type.comment.yml7
-rw-r--r--core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml33
-rw-r--r--core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml24
-rw-r--r--core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml20
-rw-r--r--core/recipes/comment_base/config/field.storage.node.comment.yml19
-rw-r--r--core/recipes/comment_base/recipe.yml26
-rw-r--r--core/recipes/content_search/recipe.yml19
-rw-r--r--core/recipes/core_recommended_admin_theme/recipe.yml24
-rw-r--r--core/recipes/core_recommended_front_end_theme/recipe.yml29
-rw-r--r--core/recipes/core_recommended_maintenance/recipe.yml16
-rw-r--r--core/recipes/core_recommended_performance/recipe.yml7
-rw-r--r--core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml52
-rw-r--r--core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml19
-rw-r--r--core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml25
-rw-r--r--core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml31
-rw-r--r--core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml29
-rw-r--r--core/recipes/document_media_type/config/field.storage.media.field_media_document.yml25
-rw-r--r--core/recipes/document_media_type/config/media.type.document.yml13
-rw-r--r--core/recipes/document_media_type/recipe.yml25
-rw-r--r--core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml60
-rw-r--r--core/recipes/editorial_workflow/recipe.yml12
-rw-r--r--core/recipes/example/composer.json9
-rw-r--r--core/recipes/example/recipe.yml46
-rw-r--r--core/recipes/feedback_contact_form/config/contact.form.feedback.yml11
-rw-r--r--core/recipes/feedback_contact_form/recipe.yml24
-rw-r--r--core/recipes/full_html_format_editor/config/editor.editor.full_html.yml102
-rw-r--r--core/recipes/full_html_format_editor/config/filter.format.full_html.yml41
-rw-r--r--core/recipes/full_html_format_editor/recipe.yml5
-rw-r--r--core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml54
-rw-r--r--core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml29
-rw-r--r--core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml30
-rw-r--r--core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml31
-rw-r--r--core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml40
-rw-r--r--core/recipes/image_media_type/config/field.storage.media.field_media_image.yml32
-rw-r--r--core/recipes/image_media_type/config/media.type.image.yml13
-rw-r--r--core/recipes/image_media_type/recipe.yml27
-rw-r--r--core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml52
-rw-r--r--core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml19
-rw-r--r--core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml32
-rw-r--r--core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml31
-rw-r--r--core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml26
-rw-r--r--core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml22
-rw-r--r--core/recipes/local_video_media_type/config/media.type.video.yml13
-rw-r--r--core/recipes/local_video_media_type/recipe.yml25
-rw-r--r--core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml21
-rw-r--r--core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml76
-rw-r--r--core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml25
-rw-r--r--core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml27
-rw-r--r--core/recipes/page_content_type/config/field.field.node.page.body.yml22
-rw-r--r--core/recipes/page_content_type/config/node.type.page.yml10
-rw-r--r--core/recipes/page_content_type/recipe.yml21
-rw-r--r--core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml53
-rw-r--r--core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml19
-rw-r--r--core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml29
-rw-r--r--core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml31
-rw-r--r--core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml18
-rw-r--r--core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml20
-rw-r--r--core/recipes/remote_video_media_type/config/media.type.remote_video.yml17
-rw-r--r--core/recipes/remote_video_media_type/recipe.yml23
-rw-r--r--core/recipes/restricted_html_format/config/filter.format.restricted_html.yml29
-rw-r--r--core/recipes/restricted_html_format/recipe.yml11
-rw-r--r--core/recipes/standard/config/user.role.administrator.yml8
-rw-r--r--core/recipes/standard/config/user.role.content_editor.yml23
-rw-r--r--core/recipes/standard/recipe.yml85
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml24
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml24
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_325x325.yml24
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_650x650.yml24
-rw-r--r--core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml22
-rw-r--r--core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml24
-rw-r--r--core/recipes/standard_responsive_images/recipe.yml8
-rw-r--r--core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml7
-rw-r--r--core/recipes/tags_taxonomy/recipe.yml11
-rw-r--r--core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml35
-rw-r--r--core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml28
-rw-r--r--core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml29
-rw-r--r--core/recipes/user_picture/config/field.field.user.user.user_picture.yml37
-rw-r--r--core/recipes/user_picture/config/field.storage.user.user_picture.yml31
-rw-r--r--core/recipes/user_picture/recipe.yml8
-rw-r--r--core/scripts/drupal2
-rw-r--r--core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php71
-rw-r--r--core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php107
-rw-r--r--core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php94
-rw-r--r--core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php129
-rw-r--r--core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php151
-rw-r--r--core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php263
-rw-r--r--core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php324
-rw-r--r--core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php310
-rw-r--r--core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php97
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php137
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php46
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php77
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php94
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php38
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php231
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php53
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php48
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php58
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php254
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php70
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php340
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php79
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php135
-rw-r--r--core/tests/Drupal/Tests/Core/Command/QuickStartTest.php2
-rw-r--r--core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php26
-rw-r--r--core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php296
-rw-r--r--core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php190
-rw-r--r--core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php51
-rw-r--r--core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php306
-rw-r--r--core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php181
-rw-r--r--core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml27
-rw-r--r--core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml24
-rw-r--r--core/tests/fixtures/default_content/druplicon.png1
-rw-r--r--core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml27
-rw-r--r--core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml27
-rw-r--r--core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml27
-rw-r--r--core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml27
-rw-r--r--core/tests/fixtures/default_content/file/druplicon.pngbin0 -> 3905 bytes
-rw-r--r--core/tests/fixtures/default_content/file/druplicon_copy.pngbin0 -> 3905 bytes
-rw-r--r--core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml38
-rw-r--r--core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml38
-rw-r--r--core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml83
-rw-r--r--core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml44
-rw-r--r--core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml46
-rw-r--r--core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml31
-rw-r--r--core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml43
-rw-r--r--core/tests/fixtures/recipes/base_theme_and_views/recipe.yml6
-rw-r--r--core/tests/fixtures/recipes/config_actions/recipe.yml13
-rw-r--r--core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml9
-rw-r--r--core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml9
-rw-r--r--core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml9
-rw-r--r--core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml4
-rw-r--r--core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml4
-rw-r--r--core/tests/fixtures/recipes/config_from_module/recipe.yml9
-rw-r--r--core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml6
-rw-r--r--core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml2
-rw-r--r--core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml11
-rw-r--r--core/tests/fixtures/recipes/config_rollback_exception/recipe.yml19
-rw-r--r--core/tests/fixtures/recipes/config_wildcard/recipe.yml10
-rw-r--r--core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml1
-rw-r--r--core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml9
-rw-r--r--core/tests/fixtures/recipes/install_node_with_config/recipe.yml5
-rw-r--r--core/tests/fixtures/recipes/install_two_modules/recipe.yml5
-rw-r--r--core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml14
-rw-r--r--core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml7
-rw-r--r--core/tests/fixtures/recipes/invalid_config/recipe.yml2
-rw-r--r--core/tests/fixtures/recipes/no_extensions/recipe.yml3
-rw-r--r--core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml4
-rw-r--r--core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml5
-rw-r--r--core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml9
-rw-r--r--core/tests/fixtures/recipes/recipe_include/recipe.yml6
-rw-r--r--core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml5
-rw-r--r--core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml12
-rw-r--r--core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml2
259 files changed, 13942 insertions, 327 deletions
diff --git a/core/core.services.yml b/core/core.services.yml
index 3ce58886ddf..561ca8c3a9c 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -67,6 +67,17 @@ parameters:
services:
_defaults:
autoconfigure: true
+ plugin.manager.config_action:
+ class: Drupal\Core\Config\Action\ConfigActionManager
+ parent: default_plugin_manager
+ arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory']
+ Drupal\Core\DefaultContent\Importer:
+ autowire: true
+ Drupal\Core\DefaultContent\AdminAccountSwitcher:
+ arguments:
+ $isSuperUserAccessEnabled: '%security.enable_super_user%'
+ autowire: true
+ public: false
# Simple cache contexts, directly derived from the request context.
cache_context.ip:
class: Drupal\Core\Cache\Context\IpCacheContext
@@ -385,6 +396,14 @@ services:
public: false
tags:
- { name: backend_overridable }
+ config.storage.checkpoint:
+ class: Drupal\Core\Config\Checkpoint\CheckpointStorage
+ arguments: [ '@config.storage', '@config.checkpoints', '@keyvalue' ]
+ Drupal\Core\Config\Checkpoint\CheckpointStorageInterface: '@config.storage.checkpoint'
+ config.checkpoints:
+ class: Drupal\Core\Config\Checkpoint\LinearHistory
+ arguments: [ '@state', '@datetime.time' ]
+ Drupal\Core\Config\Checkpoint\CheckpointListInterface: '@config.checkpoints'
config.import_transformer:
class: Drupal\Core\Config\ImportStorageTransformer
arguments: ['@event_dispatcher', '@database', '@lock', '@lock.persistent']
diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc
index 4e9f33a1e4d..fd3958d4af1 100644
--- a/core/includes/install.core.inc
+++ b/core/includes/install.core.inc
@@ -26,6 +26,8 @@ use Drupal\Core\Installer\Form\SiteSettingsForm;
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageManager;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\Translator\FileTranslation;
use Drupal\Core\StackMiddleware\ReverseProxyMiddleware;
@@ -839,6 +841,27 @@ function install_tasks($install_state) {
array_slice($tasks, $key, NULL, TRUE);
}
+ if (!empty($install_state['parameters']['recipe'])) {
+ // The install state indicates that we are installing from a recipe.
+ $key = array_search('install_profile_modules', array_keys($tasks), TRUE);
+ unset($tasks['install_profile_modules']);
+ unset($tasks['install_profile_themes']);
+ unset($tasks['install_install_profile']);
+ $recipe_tasks = [
+ 'install_recipe_required_modules' => [
+ 'display_name' => t('Install required modules'),
+ 'type' => 'batch',
+ ],
+ 'install_recipe_batch' => [
+ 'display_name' => t('Install recipe'),
+ 'type' => 'batch',
+ ],
+ ];
+ $tasks = array_slice($tasks, 0, $key, TRUE) +
+ $recipe_tasks +
+ array_slice($tasks, $key, NULL, TRUE);
+ }
+
// Now add any tasks defined by the installation profile.
if (!empty($install_state['parameters']['profile'])) {
// Load the profile install file, because it is not always loaded when
@@ -2539,3 +2562,71 @@ function _install_config_locale_overrides_process_batch(array $names, array $lan
}
$context['finished'] = 1;
}
+
+/**
+ * Installs required modules prior to applying a recipe via the installer.
+ *
+ * @see install_tasks()
+ *
+ * @internal
+ * All installer code is internal.
+ */
+function install_recipe_required_modules() {
+ // We need to manually trigger the installation of core-provided entity types,
+ // as those will not be handled by the module installer.
+ // @see install_profile_modules()
+ install_core_entity_type_definitions();
+
+ $batch_builder = new BatchBuilder();
+ $batch_builder
+ ->setFinishCallback([ConfigImporterBatch::class, 'finish'])
+ ->setTitle(t('Installing required modules'))
+ ->setInitMessage(t('Starting required module installation.'))
+ ->setErrorMessage(t('Required module installation has encountered an error.'));
+
+ $files = \Drupal::service('extension.list.module')->getList();
+
+ // Always install required modules first.
+ $required = [];
+
+ foreach ($files as $module => $extension) {
+ if (!empty($extension->info['required'])) {
+ $required[$module] = $extension->sort;
+ }
+ }
+ arsort($required);
+
+ // The system module is already installed. See install_base_system().
+ unset($required['system']);
+
+ foreach ($required as $module => $weight) {
+ $batch_builder->addOperation(
+ '_install_module_batch',
+ [$module, $files[$module]->info['name']],
+ );
+ }
+ return $batch_builder->toArray();
+}
+
+/**
+ * Creates a batch for the recipe system to process.
+ *
+ * @see install_tasks()
+ *
+ * @internal
+ * This API is experimental.
+ */
+function install_recipe_batch(&$install_state) {
+ $batch_builder = new BatchBuilder();
+ $batch_builder
+ ->setTitle(t('Installing recipe'))
+ ->setInitMessage(t('Starting recipe installation.'))
+ ->setErrorMessage(t('Recipe installation has encountered an error.'));
+
+ $recipe = Recipe::createFromDirectory($install_state['parameters']['recipe']);
+ foreach (RecipeRunner::toBatchOperations($recipe) as $step) {
+ $batch_builder->addOperation(...$step);
+ }
+
+ return $batch_builder->toArray();
+}
diff --git a/core/lib/Drupal/Core/Command/InstallCommand.php b/core/lib/Drupal/Core/Command/InstallCommand.php
index 5691f9d5416..da40673bdaa 100644
--- a/core/lib/Drupal/Core/Command/InstallCommand.php
+++ b/core/lib/Drupal/Core/Command/InstallCommand.php
@@ -48,11 +48,12 @@ class InstallCommand extends Command {
protected function configure() {
$this->setName('install')
->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
- ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
+ ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en')
->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal')
->addUsage('demo_umami --langcode fr')
- ->addUsage('standard --site-name QuickInstall');
+ ->addUsage('standard --site-name QuickInstall')
+ ->addUsage('core/recipes/standard --site-name RecipeBuiltSite');
parent::configure();
}
@@ -78,15 +79,43 @@ class InstallCommand extends Command {
return 0;
}
- $install_profile = $input->getArgument('install-profile');
- if ($install_profile && !$this->validateProfile($install_profile, $io)) {
- return 1;
- }
- if (!$install_profile) {
+ $install_profile_or_recipe = $input->getArgument('install-profile-or-recipe');
+
+ if (!$install_profile_or_recipe) {
+ // User did not provide a recipe or install profile.
$install_profile = $this->selectProfile($io);
}
+ // Determine if an install profile or a recipe has been provided.
+ elseif ($this->validateProfile($install_profile_or_recipe)) {
+ // User provided an install profile.
+ $install_profile = $install_profile_or_recipe;
+ }
+ elseif ($this->validateRecipe($install_profile_or_recipe)) {
+ // User provided a recipe.
+ $recipe = $install_profile_or_recipe;
+ }
+ else {
+ $error_msg = sprintf("'%s' is not a valid install profile or recipe.", $install_profile_or_recipe);
- return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'));
+ // If it does not look like a path make suggestions based upon available
+ // profiles.
+ if (!str_contains('/', $install_profile_or_recipe)) {
+ $alternatives = [];
+ foreach (array_keys($this->getProfiles(TRUE, FALSE)) as $profile_name) {
+ $lev = levenshtein($install_profile_or_recipe, $profile_name);
+ if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile_or_recipe)) {
+ $alternatives[] = $profile_name;
+ }
+ }
+ if (!empty($alternatives)) {
+ $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
+ }
+ }
+ $io->getErrorStyle()->error($error_msg);
+ return 1;
+ }
+
+ return $this->install($this->classLoader, $io, $install_profile ?? '', $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'), $recipe ?? '');
}
/**
@@ -123,6 +152,8 @@ class InstallCommand extends Command {
* The path to install the site to, like 'sites/default'.
* @param string $site_name
* The site name.
+ * @param string $recipe
+ * The recipe to use for installing.
*
* @throws \Exception
* Thrown when failing to create the $site_path directory or settings.php.
@@ -130,7 +161,7 @@ class InstallCommand extends Command {
* @return int
* The command exit status.
*/
- protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) {
+ protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name, string $recipe) {
$sqliteDriverNamespace = 'Drupal\\sqlite\\Driver\\Database\\sqlite';
$password = Crypt::randomBytesBase64(12);
$parameters = [
@@ -166,6 +197,9 @@ class InstallCommand extends Command {
],
],
];
+ if ($recipe) {
+ $parameters['parameters']['recipe'] = $recipe;
+ }
// Create the directory and settings.php if not there so that the installer
// works.
@@ -277,29 +311,29 @@ class InstallCommand extends Command {
*
* @param string $install_profile
* Install profile to validate.
- * @param \Symfony\Component\Console\Style\SymfonyStyle $io
- * Symfony style output decorator.
*
* @return bool
* TRUE if the profile is valid, FALSE if not.
*/
- protected function validateProfile($install_profile, SymfonyStyle $io) {
+ protected function validateProfile($install_profile): bool {
// Allow people to install hidden and non-distribution profiles if they
// supply the argument.
- $profiles = $this->getProfiles(TRUE, FALSE);
- if (!isset($profiles[$install_profile])) {
- $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile);
- $alternatives = [];
- foreach (array_keys($profiles) as $profile_name) {
- $lev = levenshtein($install_profile, $profile_name);
- if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile)) {
- $alternatives[] = $profile_name;
- }
- }
- if (!empty($alternatives)) {
- $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
- }
- $io->getErrorStyle()->error($error_msg);
+ return array_key_exists($install_profile, $this->getProfiles(TRUE, FALSE));
+ }
+
+ /**
+ * Validates a user provided recipe.
+ *
+ * @param string $recipe
+ * The path to the recipe to validate.
+ *
+ * @return bool
+ * TRUE if the recipe exists, FALSE if not.
+ */
+ protected function validateRecipe(string $recipe): bool {
+ // It is impossible to validate a recipe fully at this point because that
+ // requires a container.
+ if (!is_dir($recipe) || !is_file($recipe . '/recipe.yml')) {
return FALSE;
}
return TRUE;
diff --git a/core/lib/Drupal/Core/Command/QuickStartCommand.php b/core/lib/Drupal/Core/Command/QuickStartCommand.php
index 572f6415c20..6f16ccb9101 100644
--- a/core/lib/Drupal/Core/Command/QuickStartCommand.php
+++ b/core/lib/Drupal/Core/Command/QuickStartCommand.php
@@ -28,7 +28,7 @@ class QuickStartCommand extends Command {
protected function configure() {
$this->setName('quick-start')
->setDescription('Installs a Drupal site and runs a web server. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
- ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
+ ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en')
->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal')
->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1')
@@ -36,7 +36,8 @@ class QuickStartCommand extends Command {
->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.')
->addUsage('demo_umami --langcode fr')
->addUsage('standard --site-name QuickInstall --host localhost --port 8080')
- ->addUsage('minimal --host my-site.com --port 80');
+ ->addUsage('minimal --host my-site.com --port 80')
+ ->addUsage('core/recipes/standard --site-name MyDrupalRecipe');
parent::configure();
}
@@ -49,7 +50,7 @@ class QuickStartCommand extends Command {
$arguments = [
'command' => 'install',
- 'install-profile' => $input->getArgument('install-profile'),
+ 'install-profile-or-recipe' => $input->getArgument('install-profile-or-recipe'),
'--langcode' => $input->getOption('langcode'),
'--site-name' => $input->getOption('site-name'),
];
diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
new file mode 100644
index 00000000000..c6abf041d6c
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Attribute;
+
+// cspell:ignore inflector
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+#[\Attribute(\Attribute::TARGET_METHOD)]
+final class ActionMethod {
+
+ /**
+ * @param \Drupal\Core\Config\Action\Exists $exists
+ * Determines behavior of action depending on entity existence.
+ * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel
+ * The admin label for the user interface.
+ * @param bool|string $pluralize
+ * Determines whether to create a pluralized version of the method to enable
+ * the action to be called multiple times before saving the entity. The
+ * default behavior is to create an action with a plural form as determined
+ * by \Symfony\Component\String\Inflector\EnglishInflector::pluralize().
+ * For example, 'grantPermission' has a pluralized version of
+ * 'grantPermissions'. If a string is provided this will be the full action
+ * ID. For example, if the method is called 'addArray' this can be set to
+ * 'addMultipleArrays'. Set to FALSE if a pluralized version does not make
+ * logical sense.
+ */
+ public function __construct(
+ public readonly Exists $exists = Exists::ErrorIfNotExists,
+ public readonly TranslatableMarkup|string $adminLabel = '',
+ public readonly bool|string $pluralize = TRUE
+ ) {
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php
new file mode 100644
index 00000000000..d8df99cf100
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Attribute;
+
+use Drupal\Component\Plugin\Attribute\Plugin;
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Defines a ConfigAction attribute object.
+ *
+ * Plugin Namespace: Plugin\ConfigAction
+ *
+ * @ingroup config_action_api
+ *
+ * @internal
+ * This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Action\ConfigActionPluginInterface
+ * @see \Drupal\Core\Config\Action\ConfigActionManager
+ * @see plugin_api
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class ConfigAction extends Plugin {
+
+ /**
+ * Constructs a ConfigAction attribute.
+ *
+ * @param string $id
+ * The plugin ID.
+ * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $admin_label
+ * The administrative label of the config action. This is optional when
+ * using a deriver, but in that case the deriver should add an admin label.
+ * @param string[] $entity_types
+ * (optional) Allows action shorthand IDs for the listed config entity
+ * types. If '*' is present in the array then it can apply to all entity
+ * types. An empty array means that shorthand action IDs are not available
+ * for this plugin. See ConfigActionManager::convertActionToPluginId().
+ * @param class-string|null $deriver
+ * (optional) The deriver class.
+ *
+ * @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId()
+ */
+ public function __construct(
+ public readonly string $id,
+ public readonly ?TranslatableMarkup $admin_label = NULL,
+ public readonly array $entity_types = [],
+ public readonly ?string $deriver = NULL,
+ ) {
+ if ($this->admin_label === NULL && $this->deriver === NULL) {
+ throw new InvalidPluginDefinitionException($id, sprintf("The '%s' config action plugin must have either an admin label or a deriver", $id));
+ }
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionException.php b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
new file mode 100644
index 00000000000..3ad3579b260
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class ConfigActionException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
new file mode 100644
index 00000000000..cb030d6ba33
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
@@ -0,0 +1,221 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+use Drupal\Component\Plugin\PluginBase;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Config\Schema\Mapping;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Config\TypedConfigManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint;
+
+/**
+ * @defgroup config_action_api Config Action API
+ * @{
+ * Information about the classes and interfaces that make up the Config Action
+ * API.
+ *
+ * Configuration actions are plugins that manipulate simple configuration or
+ * configuration entities. The configuration action plugin manager can apply
+ * configuration actions. For example, the API is leveraged by recipes to create
+ * roles if they do not exist already and grant permissions to those roles.
+ *
+ * To define a configuration action in a module you need to:
+ * - Define a Config Action plugin by creating a new class that implements the
+ * \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace
+ * Plugin\ConfigAction under your module namespace. For more information about
+ * creating plugins, see the @link plugin_api Plugin API topic. @endlink
+ * - Config action plugins use the attributes defined by
+ * \Drupal\Core\Config\Action\Attribute\ConfigAction. See the
+ * @link attribute Attributes topic @endlink for more information about
+ * attributes.
+ *
+ * Further information and examples:
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives
+ * configuration actions from config entity methods which have the
+ * \Drupal\Core\Config\Action\Attribute\ActionMethod attribute.
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to
+ * create configuration entities if they do not exist.
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows
+ * you to update simple configuration using a config action.
+ * @}
+ *
+ * @internal
+ * This API is experimental.
+ */
+class ConfigActionManager extends DefaultPluginManager {
+
+ /**
+ * Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object.
+ *
+ * @param \Traversable $namespaces
+ * An object that implements \Traversable which contains the root paths
+ * keyed by the corresponding namespace to look for plugin implementations.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * Cache backend instance to use.
+ * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+ * The module handler to invoke the alter hook with.
+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+ * The config manager.
+ * @param \Drupal\Core\Config\StorageInterface $configStorage
+ * The active config storage.
+ * @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfig
+ * The typed configuration manager service.
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+ * The config factory service.
+ */
+ public function __construct(
+ \Traversable $namespaces,
+ CacheBackendInterface $cache_backend,
+ ModuleHandlerInterface $module_handler,
+ protected readonly ConfigManagerInterface $configManager,
+ protected readonly StorageInterface $configStorage,
+ protected readonly TypedConfigManagerInterface $typedConfig,
+ protected readonly ConfigFactoryInterface $configFactory,
+ ) {
+ assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array');
+ // Enable this namespace to be searched for plugins.
+ $namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action';
+
+ parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, ConfigActionPluginInterface::class, ConfigAction::class);
+
+ $this->alterInfo('config_action');
+ $this->setCacheBackend($cache_backend, 'config_action');
+ }
+
+ /**
+ * Applies a config action.
+ *
+ * @param string $action_id
+ * The ID of the action to apply. This can be a complete configuration
+ * action plugin ID or a shorthand action ID that is available for the
+ * entity type of the provided configuration name.
+ * @param string $configName
+ * The configuration name. This may be the full name of a config object, or
+ * it may contain wildcards (to target all config entities of a specific
+ * type, or a subset thereof). See
+ * ConfigActionManager::getConfigNamesMatchingExpression() for more detail.
+ * @param mixed $data
+ * The data for the action.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\PluginException
+ * Thrown when the config action cannot be found.
+ * @throws \Drupal\Core\Config\Action\ConfigActionException
+ * Thrown when the config action fails to apply.
+ *
+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getConfigNamesMatchingExpression()
+ */
+ public function applyAction(string $action_id, string $configName, mixed $data): void {
+ if (!$this->hasDefinition($action_id)) {
+ // Get the full plugin ID from the shorthand map, if it is available.
+ $entity_type = $this->configManager->getEntityTypeIdByName($configName);
+ if ($entity_type) {
+ $action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id;
+ }
+ }
+ /** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */
+ $action = $this->createInstance($action_id);
+ foreach ($this->getConfigNamesMatchingExpression($configName) as $name) {
+ $action->apply($name, $data);
+ $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData());
+ // All config objects are mappings.
+ assert($typed_config instanceof Mapping);
+ foreach ($typed_config->getConstraints() as $constraint) {
+ // Only validate the config if it has explicitly been marked as being
+ // validatable.
+ if ($constraint instanceof FullyValidatableConstraint) {
+ /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+ $violations = $typed_config->validate();
+ if (count($violations) > 0) {
+ throw new InvalidConfigException($violations, $typed_config);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the names of all active config objects that match an expression.
+ *
+ * @param string $expression
+ * The expression to match. This may be the full name of a config object,
+ * or it may contain wildcards (to target all config entities of a specific
+ * type, or a subset thereof). For example:
+ * - `user.role.*` would target all user roles.
+ * - `user.role.anonymous` would target only the anonymous user role.
+ * - `core.entity_view_display.node.*.default` would target the default
+ * view display of every content type.
+ * - `core.entity_form_display.*.*.default` would target the default form
+ * display of every bundle of every entity type.
+ * The expression MUST begin with the prefix of a config entity type --
+ * for example, `field.field.` in the case of fields, or `user.role.` for
+ * user roles. The prefix cannot contain wildcards.
+ *
+ * @return string[]
+ * The names of all active config objects that match the expression.
+ *
+ * @throws \Drupal\Core\Config\Action\ConfigActionException
+ * Thrown if the expression does not match any known config entity type's
+ * prefix, or if the expression cannot be parsed.
+ */
+ private function getConfigNamesMatchingExpression(string $expression): array {
+ // If there are no wildcards, we can return the config name as-is.
+ if (!str_contains($expression, '.*')) {
+ return [$expression];
+ }
+
+ $entity_type = $this->configManager->getEntityTypeIdByName($expression);
+ if (empty($entity_type)) {
+ throw new ConfigActionException("No installed config entity type uses the prefix in the expression '$expression'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe.");
+ }
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+ $entity_type = $this->configManager->getEntityTypeManager()
+ ->getDefinition($entity_type);
+ $prefix = $entity_type->getConfigPrefix();
+
+ // Convert the expression to a regular expression. We assume that * should
+ // match the characters allowed by
+ // \Drupal\Core\Config\ConfigBase::validateName(), which is permissive.
+ $expression = str_replace('\\*', '[^.:?*<>"\'\/\\\\]+', preg_quote($expression));
+ $matches = @preg_grep("/^$expression$/", $this->configStorage->listAll("$prefix."));
+ if ($matches === FALSE) {
+ throw new ConfigActionException("The expression '$expression' could not be parsed.");
+ }
+ return $matches;
+ }
+
+ /**
+ * Gets a map of shorthand action IDs to plugin IDs for an entity type.
+ *
+ * @param string $entityType
+ * The entity type ID to get the map for.
+ *
+ * @return string[]
+ * An array of plugin IDs keyed by shorthand action ID for the provided
+ * entity type.
+ */
+ protected function getShorthandActionIdsForEntityType(string $entityType): array {
+ $map = [];
+ foreach ($this->getDefinitions() as $plugin_id => $definition) {
+ if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) {
+ $regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/';
+ $action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id;
+ if (isset($map[$action_id])) {
+ throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType));
+ }
+ $map[$action_id] = $plugin_id;
+ }
+ }
+ return $map;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
new file mode 100644
index 00000000000..6431ec959e8
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+interface ConfigActionPluginInterface {
+
+ /**
+ * Applies the config action.
+ *
+ * @param string $configName
+ * The name of the config to apply the action to.
+ * @param mixed $value
+ * The value for the action to use.
+ *
+ * @throws ConfigActionException
+ */
+ public function apply(string $configName, mixed $value): void;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
new file mode 100644
index 00000000000..f1d69c57a31
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * Exception thrown if there are conflicting shorthand action IDs.
+ *
+ * @internal
+ * This API is experimental.
+ */
+class DuplicateConfigActionIdException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/EntityMethodException.php b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
new file mode 100644
index 00000000000..3f4e71ec797
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class EntityMethodException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Exists.php b/core/lib/Drupal/Core/Config/Action/Exists.php
new file mode 100644
index 00000000000..6649962477b
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Exists.php
@@ -0,0 +1,44 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+enum Exists {
+ case ErrorIfExists;
+ case ErrorIfNotExists;
+ case ReturnEarlyIfExists;
+ case ReturnEarlyIfNotExists;
+
+ /**
+ * Determines if an action should return early depending on $entity.
+ *
+ * @param string $configName
+ * The config name supplied to the action.
+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity
+ * The entity, if it exists.
+ *
+ * @return bool
+ * TRUE if the action should return early, FALSE if not.
+ *
+ * @throws \Drupal\Core\Config\Action\ConfigActionException
+ * Thrown depending on $entity and the value of $this.
+ */
+ public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool {
+ return match (TRUE) {
+ $this === self::ReturnEarlyIfExists && $entity !== NULL,
+ $this === self::ReturnEarlyIfNotExists && $entity === NULL => TRUE,
+ $this === self::ErrorIfExists && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)),
+ $this === self::ErrorIfNotExists && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)),
+ default => FALSE
+ };
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
new file mode 100644
index 00000000000..c5f73f24385
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class EntityCreateDeriver extends DeriverBase {
+ use StringTranslationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ // These derivatives apply to all entity types.
+ $base_plugin_definition['entity_types'] = ['*'];
+
+ $this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ReturnEarlyIfExists]];
+ $this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists');
+
+ $this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ErrorIfExists]];
+ $this->derivatives['create']['admin_label'] = $this->t('Entity create');
+
+ return $this->derivatives;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
new file mode 100644
index 00000000000..2577d8d7b92
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+// cspell:ignore inflector
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Component\Plugin\PluginBase;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\String\Inflector\EnglishInflector;
+use Symfony\Component\String\Inflector\InflectorInterface;
+
+/**
+ * Derives config action methods from attributed config entity methods.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * Inflector to pluralize words.
+ */
+ protected readonly InflectorInterface $inflector;
+
+ /**
+ * Constructs new EntityMethodDeriver.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+ * The entity type manager.
+ */
+ public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) {
+ $this->inflector = new EnglishInflector();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, $base_plugin_id) {
+ return new static(
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ // Scan all the config entity classes for attributes.
+ foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
+ if ($entity_type instanceof ConfigEntityTypeInterface) {
+ $reflectionClass = new \ReflectionClass($entity_type->getClass());
+ while ($reflectionClass) {
+ foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ // Only process a method if it is declared on the current class.
+ // Methods on the parent class will be processed later. This allows
+ // for a parent to have an attribute and an overriding class does
+ // not need one. For example,
+ // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent()
+ // and \Drupal\Core\Entity\EntityDisplayBase::setComponent().
+ if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) {
+ foreach ($method->getAttributes(ActionMethod::class) as $attribute) {
+ $this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition);
+ }
+ }
+ }
+ $reflectionClass = $reflectionClass->getParentClass();
+ }
+ }
+ }
+ return $this->derivatives;
+ }
+
+ /**
+ * Processes a method to create derivatives.
+ *
+ * @param \ReflectionMethod $method
+ * The entity method.
+ * @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute
+ * The entity method attribute.
+ * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
+ * The entity type.
+ * @param array $derivative
+ * The base plugin definition that will used to create the derivative.
+ */
+ private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void {
+ $derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]);
+ $derivative['constructor_args'] = [
+ 'method' => $method->name,
+ 'exists' => $action_attribute->exists,
+ 'numberOfParams' => $method->getNumberOfParameters(),
+ 'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(),
+ 'pluralized' => FALSE,
+ ];
+ $derivative['entity_types'] = [$entity_type->id()];
+ // Build a config action identifier from the entity type's config
+ // prefix and the method name. For example, the Role entity adds a
+ // 'user.role:grantPermission' action.
+ $this->addDerivative($method->name, $entity_type, $derivative, $method->name);
+
+ $pluralized_name = match(TRUE) {
+ is_string($action_attribute->pluralize) => $action_attribute->pluralize,
+ $action_attribute->pluralize === FALSE => '',
+ default => $this->inflector->pluralize($method->name)[0]
+ };
+ // Add a pluralized version of the plugin.
+ if (strlen($pluralized_name) > 0) {
+ $derivative['constructor_args']['pluralized'] = TRUE;
+ $derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]);
+ $this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name);
+ }
+ }
+
+ /**
+ * Adds a derivative.
+ *
+ * @param string $action_id
+ * The action ID.
+ * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
+ * The entity type.
+ * @param array $derivative
+ * The derivative definition.
+ * @param string $methodName
+ * The method name.
+ */
+ private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void {
+ $id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id;
+ if (isset($this->derivatives[$id])) {
+ throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method']));
+ }
+ $this->derivatives[$id] = $derivative;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php
new file mode 100644
index 00000000000..0b30e514805
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Symfony\Component\DependencyInjection\Container;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class PermissionsPerBundleDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+ public function __construct(
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, $base_plugin_id) {
+ return new static(
+ $container->get(EntityTypeManagerInterface::class),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
+ if ($entity_type->getPermissionGranularity() === 'bundle' && ($bundle_entity_type = $entity_type->getBundleEntityType()) !== NULL) {
+ // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
+ // like `TaxonomyVocabulary`.
+ $suffix = Container::camelize($bundle_entity_type);
+
+ $this->derivatives["grantPermissionsForEach{$suffix}"] = [
+ 'target_entity_type' => $id,
+ ] + $base_plugin_definition;
+ }
+ }
+ return parent::getDerivativeDefinitions($base_plugin_definition);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
new file mode 100644
index 00000000000..ad33645b3dd
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+#[ConfigAction(
+ id: 'entity_create',
+ deriver: EntityCreateDeriver::class,
+)]
+final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ /**
+ * Constructs a EntityCreate object.
+ *
+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+ * The config manager.
+ * @param \Drupal\Core\Config\Action\Exists $exists
+ * Determines behavior of action depending on entity existence.
+ */
+ public function __construct(
+ protected readonly ConfigManagerInterface $configManager,
+ protected readonly Exists $exists
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
+ return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ if (!is_array($value)) {
+ throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName));
+ }
+
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
+ $entity = $this->configManager->loadConfigEntityByName($configName);
+ if ($this->exists->returnEarly($configName, $entity)) {
+ return;
+ }
+
+ $entity_type_manager = $this->configManager->getEntityTypeManager();
+ $entity_type_id = $this->configManager->getEntityTypeIdByName($configName);
+ if ($entity_type_id === NULL) {
+ throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName));
+ }
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+ $entity_type = $entity_type_manager->getDefinition($entity_type_id);
+
+ $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
+ $entity_type_manager
+ ->getStorage($entity_type->id())
+ ->create($value + ['id' => $id])
+ ->save();
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
new file mode 100644
index 00000000000..73189f45116
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Makes config entity methods with the ActionMethod attribute into actions.
+ *
+ * For example, adding the ActionMethod attribute to
+ * \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to
+ * roles via config actions.
+ *
+ * When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction()
+ * the $data parameter is mapped to the method's arguments using the following
+ * rules:
+ * - If $data is not an array, the method must only have one argument or one
+ * required argument.
+ * - If $data is an array and the method only accepts a single argument, the
+ * array will be passed to the first argument.
+ * - If $data is an array and the method accepts more than one argument, $data
+ * will be unpacked into the method arguments.
+ *
+ * @internal
+ * This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Action\Attribute\ActionMethod
+ */
+#[ConfigAction(
+ id: 'entity_method',
+ deriver: EntityMethodDeriver::class,
+)]
+final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ /**
+ * Constructs a EntityMethod object.
+ *
+ * @param string $pluginId
+ * The config action plugin ID.
+ * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+ * The config manager.
+ * @param string $method
+ * The method to call on the config entity.
+ * @param \Drupal\Core\Config\Action\Exists $exists
+ * Determines behavior of action depending on entity existence.
+ * @param int $numberOfParams
+ * The number of parameters the method has.
+ * @param int $numberOfRequiredParams
+ * The number of required parameters the method has.
+ * @param bool $pluralized
+ * Determines whether an array maps to multiple calls.
+ */
+ public function __construct(
+ protected readonly string $pluginId,
+ protected readonly ConfigManagerInterface $configManager,
+ protected readonly string $method,
+ protected readonly Exists $exists,
+ protected readonly int $numberOfParams,
+ protected readonly int $numberOfRequiredParams,
+ protected readonly bool $pluralized
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
+ return new static(
+ $plugin_id,
+ $container->get('config.manager'),
+ ...$plugin_definition['constructor_args']
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
+ $entity = $this->configManager->loadConfigEntityByName($configName);
+ if ($this->exists->returnEarly($configName, $entity)) {
+ return;
+ }
+
+ $entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value);
+ $entity->save();
+ }
+
+ /**
+ * Applies the action to entity treating the $values array as multiple calls.
+ *
+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+ * The entity to apply the action to.
+ * @param mixed $values
+ * The values for the action to use.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
+ * The unsaved entity with the action applied.
+ */
+ private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface {
+ if (!is_array($values)) {
+ throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
+ }
+ foreach ($values as $value) {
+ $entity = $this->applySingle($entity, $value);
+ }
+ return $entity;
+ }
+
+ /**
+ * Applies the action to entity treating the $values array a single call.
+ *
+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+ * The entity to apply the action to.
+ * @param mixed $value
+ * The value for the action to use.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
+ * The unsaved entity with the action applied.
+ */
+ private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface {
+ // If $value is not an array then we only support calling the method if the
+ // number of parameters or required parameters is 1. If there is only 1
+ // parameter and $value is an array then assume that the parameter expects
+ // an array.
+ if (!is_array($value) || $this->numberOfParams === 1) {
+ if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) {
+ throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
+ }
+ $entity->{$this->method}($value);
+ }
+ else {
+ $entity->{$this->method}(...$value);
+ }
+ return $entity;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php
new file mode 100644
index 00000000000..50319686980
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\user\RoleInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+#[ConfigAction(
+ id: 'permissions_per_bundle',
+ entity_types: ['user_role'],
+ deriver: PermissionsPerBundleDeriver::class,
+)]
+final class PermissionsPerBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ public function __construct(
+ private readonly ConfigManagerInterface $configManager,
+ private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo,
+ private readonly string $targetEntityType,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ assert(is_array($plugin_definition));
+ $target_entity_type = $plugin_definition['target_entity_type'];
+
+ return new static(
+ $container->get(ConfigManagerInterface::class),
+ $container->get(EntityTypeBundleInfoInterface::class),
+ $target_entity_type,
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ $role = $this->configManager->loadConfigEntityByName($configName);
+ if (!($role instanceof RoleInterface)) {
+ throw new ConfigActionException(sprintf("Cannot determine role from %s", $configName));
+ }
+
+ assert(is_string($value) || is_array($value));
+ [$permissions, $except_bundles] = self::parseValue($value);
+
+ if (empty($permissions) || !Inspector::assertAllMatch('%bundle', $permissions, TRUE)) {
+ throw new ConfigActionException(sprintf("The permissions provided %s must be an array of strings that contain '%%bundle'.", var_export($value, TRUE)));
+ }
+
+ $bundles = $this->entityTypeBundleInfo->getBundleInfo($this->targetEntityType);
+ foreach (array_keys($bundles) as $bundle_id) {
+ if (in_array($bundle_id, $except_bundles, TRUE)) {
+ continue;
+ }
+ /** @var string[] $actual_permissions */
+ $actual_permissions = str_replace('%bundle', $bundle_id, $permissions);
+ array_walk($actual_permissions, $role->grantPermission(...));
+ }
+ $role->save();
+ }
+
+ /**
+ * Parses the value supplied to ::apply().
+ *
+ * @param string|array<string|string[]> $value
+ * One of:
+ * - A single string (a permission template).
+ * - An array of strings (several permission templates).
+ * - An array with a `permissions` element, and an optional `except`
+ * element, either of which can be an array or a string. `except` accepts
+ * a single bundle, or a list of bundles, to exclude from the permissions
+ * being granted.
+ *
+ * @return array<int, array<int<0, max>, array<string>|string>>
+ * An indexed array with two elements: the array of permissions to grant,
+ * and the list of bundles to ignore.
+ */
+ private static function parseValue(string|array $value): array {
+ if (is_string($value)) {
+ return [[$value], []];
+ }
+
+ if (array_is_list($value)) {
+ return [$value, []];
+ }
+
+ $permissions = $value['permissions'] ?? [];
+ $except_bundles = $value['except'] ?? [];
+ return [(array) $permissions, (array) $except_bundles];
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
new file mode 100644
index 00000000000..d6485304cc0
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+#[ConfigAction(
+ id: 'simple_config_update',
+ admin_label: new TranslatableMarkup('Simple configuration update'),
+)]
+final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ /**
+ * Constructs a SimpleConfigUpdate object.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+ * The config factory.
+ */
+ public function __construct(
+ protected readonly ConfigFactoryInterface $configFactory,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ return new static($container->get('config.factory'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ $config = $this->configFactory->getEditable($configName);
+ // @todo https://www.drupal.org/i/3439713 Should we error if this is a
+ // config entity?
+ if ($config->isNew()) {
+ throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName));
+ }
+
+ // Expect $value to be an array whose keys are the config keys to update.
+ if (!is_array($value)) {
+ throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName));
+ }
+ foreach ($value as $key => $value) {
+ $config->set($key, $value);
+ }
+ $config->save();
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php
new file mode 100644
index 00000000000..46e1247f728
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * A value object to store information about a checkpoint.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class Checkpoint {
+
+ /**
+ * Constructs a checkpoint object.
+ *
+ * @param string $id
+ * The checkpoint's ID.
+ * @param \Stringable|string $label
+ * The human-readable label.
+ * @param int $timestamp
+ * The timestamp when the checkpoint was created.
+ * @param string|null $parent
+ * The ID of the checkpoint's parent.
+ */
+ public function __construct(
+ public readonly string $id,
+ public readonly \Stringable|string $label,
+ public readonly int $timestamp,
+ public readonly ?string $parent,
+ ) {
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php
new file mode 100644
index 00000000000..35a203b8be7
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when trying to add a checkpoint with an ID that already exists.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class CheckpointExistsException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php
new file mode 100644
index 00000000000..dd3af0b07dd
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Maintains a list of checkpoints.
+ *
+ * @internal
+ * This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Checkpoint\Checkpoint
+ *
+ * @phpstan-extends \IteratorAggregate<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+ */
+interface CheckpointListInterface extends \IteratorAggregate, \Countable {
+
+ /**
+ * Gets the active checkpoint.
+ *
+ * @return \Drupal\Core\Config\Checkpoint\Checkpoint|null
+ * The active checkpoint or NULL if there are no checkpoints.
+ */
+ public function getActiveCheckpoint(): ?Checkpoint;
+
+ /**
+ * Gets a checkpoint.
+ *
+ * @param string $id
+ * The checkpoint ID.
+ *
+ * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+ * The checkpoint.
+ *
+ * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+ * Thrown when the provided checkpoint does not exist.
+ */
+ public function get(string $id): Checkpoint;
+
+ /**
+ * Gets a checkpoint's parents.
+ *
+ * @param string $id
+ * The checkpoint ID.
+ *
+ * @return iterable<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+ */
+ public function getParents(string $id): iterable;
+
+ /**
+ * Adds a new checkpoint.
+ *
+ * @param string $id
+ * The ID of the checkpoint add.
+ * @param string|\Stringable $label
+ * The checkpoint label.
+ *
+ * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+ * The new checkpoint, which is now at the end of the checkpoint sequence.
+ *
+ * @throws \Drupal\Core\Config\Checkpoint\CheckpointExistsException
+ * Thrown when the ID already exists.
+ */
+ public function add(string $id, string|\Stringable $label): Checkpoint;
+
+ /**
+ * Deletes a checkpoint.
+ *
+ * @param string $id
+ * The ID of the checkpoint to delete up to: only checkpoints after this one
+ * will remain.
+ *
+ * @return $this
+ *
+ * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+ * Thrown when provided checkpoint ID does not exist.
+ */
+ public function delete(string $id): static;
+
+ /**
+ * Deletes all checkpoints.
+ *
+ * @return $this
+ */
+ public function deleteAll(): static;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php
new file mode 100644
index 00000000000..d139ce82b6b
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php
@@ -0,0 +1,494 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Core\Config\Config;
+use Drupal\Core\Config\ConfigCollectionEvents;
+use Drupal\Core\Config\ConfigCrudEvent;
+use Drupal\Core\Config\ConfigEvents;
+use Drupal\Core\Config\ConfigRenameEvent;
+use Drupal\Core\Config\StorableConfigBase;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Provides a config storage that can make checkpoints.
+ *
+ * This storage wraps the active storage, and provides the ability to take
+ * checkpoints. Once a checkpoint has been created all configuration operations
+ * made after the checkpoint will be recorded, so it is possible to revert to
+ * original state when the checkpoint was taken.
+ *
+ * This class cannot be used to checkpoint another storage since it relies on
+ * events triggered by the configuration system in order to work. It is the
+ * responsibility of the caller to construct this class with the active storage.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class CheckpointStorage implements CheckpointStorageInterface, EventSubscriberInterface, LoggerAwareInterface {
+
+ use LoggerAwareTrait;
+
+ /**
+ * Used as prefix to a config checkpoint collection.
+ *
+ * If this code is copied in order to checkpoint a different storage then
+ * this value must be changed.
+ */
+ private const KEY_VALUE_COLLECTION_PREFIX = 'config.checkpoint.';
+
+ /**
+ * Used to store the list of collections in each checkpoint.
+ *
+ * Note this cannot be a valid configuration name.
+ *
+ * @see \Drupal\Core\Config\ConfigBase::validateName()
+ */
+ private const CONFIG_COLLECTION_KEY = 'collections';
+
+ /**
+ * The key value stores that store configuration changed for each checkpoint.
+ *
+ * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface[]
+ */
+ private array $keyValueStores;
+
+ /**
+ * The checkpoint to read from.
+ *
+ * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null
+ */
+ private ?Checkpoint $readFromCheckpoint = NULL;
+
+ /**
+ * Constructs a CheckpointStorage object.
+ *
+ * @param \Drupal\Core\Config\StorageInterface $activeStorage
+ * The active configuration storage.
+ * @param \Drupal\Core\Config\Checkpoint\CheckpointListInterface $checkpoints
+ * The list of checkpoints.
+ * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValueFactory
+ * The key value factory.
+ * @param string $collection
+ * (optional) The configuration collection.
+ */
+ public function __construct(
+ private readonly StorageInterface $activeStorage,
+ private readonly CheckpointListInterface $checkpoints,
+ private readonly KeyValueFactoryInterface $keyValueFactory,
+ private readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exists($name) {
+ if (count($this->checkpoints) === 0) {
+ throw new NoCheckpointsException();
+ }
+
+ foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+ $in_checkpoint = $this->getKeyValue($checkpoint->id, $this->collection)->get($name);
+ if ($in_checkpoint !== NULL) {
+ // If $in_checkpoint is FALSE then the configuration has been deleted.
+ return $in_checkpoint !== FALSE;
+ }
+ }
+ return $this->activeStorage->exists($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function read($name) {
+ $return = $this->readMultiple([$name]);
+ return $return[$name] ?? FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function readMultiple(array $names) {
+ if (count($this->checkpoints) === 0) {
+ throw new NoCheckpointsException();
+ }
+ $return = [];
+
+ foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+ $return = array_merge(
+ $return,
+ $this->getKeyValue($checkpoint->id, $this->collection)->getMultiple($names)
+ );
+ // Remove the read names from the list to fetch.
+ $names = array_diff($names, array_keys($return));
+ if (empty($names)) {
+ // All the configuration has been read. Nothing more to do.
+ break;
+ }
+ }
+
+ // Names not found in the checkpoints have not been modified: read from
+ // active storage.
+ if (!empty($names)) {
+ $return = array_merge(
+ $return,
+ $this->activeStorage->readMultiple($names)
+ );
+ }
+
+ // Remove any renamed or new configuration (FALSE has been recorded for
+ // these operations in the checkpoint).
+ // @see ::onConfigRename()
+ // @see ::onConfigSaveAndDelete()
+ return array_filter($return);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function encode($data) {
+ return $this->activeStorage->encode($data);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decode($raw) {
+ return $this->activeStorage->decode($raw);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function listAll($prefix = '') {
+ if (count($this->checkpoints) === 0) {
+ throw new NoCheckpointsException();
+ }
+
+ $names = $new_configuration = [];
+
+ foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+ $checkpoint_names = array_keys(array_filter($this->getKeyValue($checkpoint->id, $this->collection)->getAll(), function (mixed $value, string $name) use (&$new_configuration, $prefix) {
+ if ($name === static::CONFIG_COLLECTION_KEY) {
+ return FALSE;
+ }
+ // Remove any that don't start with the prefix.
+ if ($prefix !== '' && !str_starts_with($name, $prefix)) {
+ return FALSE;
+ }
+ // We've determined in a previous checkpoint that the configuration did
+ // not exist.
+ if (in_array($name, $new_configuration, TRUE)) {
+ return FALSE;
+ }
+ // If the value is FALSE then the configuration was created after the
+ // checkpoint.
+ if ($value === FALSE) {
+ $new_configuration[] = $name;
+ return FALSE;
+ }
+ return TRUE;
+ }, ARRAY_FILTER_USE_BOTH));
+ $names = array_merge($names, $checkpoint_names);
+ }
+
+ // Remove any names that did not exist prior to the checkpoint.
+ $active_names = array_diff($this->activeStorage->listAll($prefix), $new_configuration);
+
+ $names = array_unique(array_merge($names, $active_names));
+ sort($names);
+ return $names;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createCollection($collection) {
+ $collection = new self(
+ $this->activeStorage->createCollection($collection),
+ $this->checkpoints,
+ $this->keyValueFactory,
+ $collection
+ );
+ // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint is
+ // assigned by reference so that it is consistent across all collection
+ // objects created from the same initial object.
+ $collection->readFromCheckpoint = &$this->readFromCheckpoint;
+ return $collection;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllCollectionNames() {
+ $names = [];
+ foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+ $names = array_merge(
+ $names,
+ $this->getKeyValue($checkpoint->id, StorageInterface::DEFAULT_COLLECTION)->get(static::CONFIG_COLLECTION_KEY, [])
+ );
+ }
+ return array_unique(array_merge($this->activeStorage->getAllCollectionNames(), $names));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCollectionName() {
+ return $this->collection;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function checkpoint(string|\Stringable $label): Checkpoint {
+ // Generate a new ID based on the state of the current active checkpoint.
+ $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+ if (!$active_checkpoint instanceof Checkpoint) {
+ // @todo https://www.drupal.org/i/3408525 Consider options for generating
+ // a real fingerprint.
+ $id = hash('sha1', random_bytes(32));
+ return $this->checkpoints->add($id, $label);
+ }
+
+ // Determine if we need to create a new checkpoint by checking if
+ // configuration has changed since the last checkpoint.
+ $collections = $this->getAllCollectionNames();
+ $collections[] = StorageInterface::DEFAULT_COLLECTION;
+ foreach ($collections as $collection) {
+ $current_checkpoint_data[$collection] = $this->getKeyValue($active_checkpoint->id, $collection)->getAll();
+ // Remove the collections key because it is irrelevant.
+ unset($current_checkpoint_data[$collection][static::CONFIG_COLLECTION_KEY]);
+ // If there is no data in the collection then there is no need to hash
+ // the empty array.
+ if (empty($current_checkpoint_data[$collection])) {
+ unset($current_checkpoint_data[$collection]);
+ }
+ }
+
+ if (!empty($current_checkpoint_data)) {
+ // Use json_encode() here because it is both quicker and results in
+ // smaller output than serialize().
+ $id = hash('sha1', ($active_checkpoint->parent ?? '') . json_encode($current_checkpoint_data));
+ return $this->checkpoints->add($id, $label);
+ }
+
+ $this->logger?->notice('A backup checkpoint was not created because nothing has changed since the "{active}" checkpoint was created.', [
+ 'active' => $active_checkpoint->label,
+ ]);
+ return $active_checkpoint;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static {
+ if ($checkpoint_id instanceof Checkpoint) {
+ $checkpoint_id = $checkpoint_id->id;
+ }
+ $this->readFromCheckpoint = $this->checkpoints->get($checkpoint_id);
+ return $this;
+ }
+
+ /**
+ * Gets the key value storage for the provided checkpoint.
+ *
+ * @param string $checkpoint
+ * The checkpoint to get the key value storage for.
+ * @param string $collection
+ * The config collection to get the key value storage for.
+ *
+ * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+ * The key value storage for the provided checkpoint.
+ */
+ private function getKeyValue(string $checkpoint, string $collection): KeyValueStoreInterface {
+ $checkpoint_key = $checkpoint;
+ if ($collection !== StorageInterface::DEFAULT_COLLECTION) {
+ $checkpoint_key = $collection . '.' . $checkpoint_key;
+ }
+ return $this->keyValueStores[$checkpoint_key] ??= $this->keyValueFactory->get(self::KEY_VALUE_COLLECTION_PREFIX . $checkpoint_key);
+ }
+
+ /**
+ * Gets the checkpoints to read from.
+ *
+ * @return \Traversable<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+ * The checkpoints, keyed by ID.
+ */
+ private function getCheckpointsToReadFrom(): \Traversable {
+ $checkpoint = $this->checkpoints->getActiveCheckpoint();
+
+ /** @var \Drupal\Core\Config\Checkpoint\Checkpoint[] $checkpoints_to_read_from */
+ $checkpoints_to_read_from = [$checkpoint];
+ if ($checkpoint->id !== $this->readFromCheckpoint?->id) {
+ // Follow ancestors to find the checkpoint to start reading from.
+ foreach ($this->checkpoints->getParents($checkpoint->id) as $checkpoint) {
+ array_unshift($checkpoints_to_read_from, $checkpoint);
+ if ($checkpoint->id === $this->readFromCheckpoint?->id) {
+ break;
+ }
+ }
+ }
+
+ // Replay in parent to child order.
+ foreach ($checkpoints_to_read_from as $checkpoint) {
+ yield $checkpoint->id => $checkpoint;
+ }
+ }
+
+ /**
+ * Updates checkpoint when configuration is saved.
+ *
+ * @param \Drupal\Core\Config\ConfigCrudEvent $event
+ * The configuration event.
+ */
+ public function onConfigSaveAndDelete(ConfigCrudEvent $event): void {
+ $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+ if ($active_checkpoint === NULL) {
+ return;
+ }
+
+ $saved_config = $event->getConfig();
+ $collection = $saved_config->getStorage()->getCollectionName();
+ $this->storeCollectionName($collection);
+
+ $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
+
+ // If we have not yet stored a checkpoint for this configuration we should.
+ if ($key_value->get($saved_config->getName()) === NULL) {
+ $original_data = $this->getOriginalConfig($saved_config);
+ // An empty array indicates that the config has to be new as a sequence
+ // cannot be the root of a config object. We need to make this assumption
+ // because $saved_config->isNew() will always return FALSE here.
+ if (empty($original_data)) {
+ $original_data = FALSE;
+ }
+ // Only save change to state if there is a change, even if it's just keys
+ // being re-ordered.
+ if ($original_data !== $saved_config->getRawData()) {
+ $key_value->set($saved_config->getName(), $original_data);
+ }
+ }
+ }
+
+ /**
+ * Updates checkpoint when configuration is saved.
+ *
+ * @param \Drupal\Core\Config\ConfigRenameEvent $event
+ * The configuration event.
+ */
+ public function onConfigRename(ConfigRenameEvent $event): void {
+ $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+ if ($active_checkpoint === NULL) {
+ return;
+ }
+ $collection = $event->getConfig()->getStorage()->getCollectionName();
+ $this->storeCollectionName($collection);
+
+ $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
+
+ $old_name = $event->getOldName();
+
+ // If we have not yet stored a checkpoint for this configuration, store a
+ // complete copy of the original configuration. Note that renames do not
+ // change data but storing the complete data allows
+ // \Drupal\Core\Config\ConfigImporter to track renames using UUIDs.
+ if ($key_value->get($old_name) === NULL) {
+ $key_value->set($old_name, $this->getOriginalConfig($event->getConfig()));
+ }
+
+ // Record that the new name did not exist prior to the checkpoint.
+ $new_name = $event->getConfig()->getName();
+ if ($key_value->get($new_name) === NULL) {
+ $key_value->set($new_name, FALSE);
+ }
+ }
+
+ /**
+ * Gets the original data from the configuration.
+ *
+ * @param \Drupal\Core\Config\StorableConfigBase $config
+ * The config to get the original data from.
+ *
+ * @return mixed
+ * The original data.
+ */
+ private function getOriginalConfig(StorableConfigBase $config): mixed {
+ if ($config instanceof Config) {
+ return $config->getOriginal(apply_overrides: FALSE);
+ }
+ return $config->getOriginal();
+ }
+
+ /**
+ * Stores the collection name so the storage knows its own collections.
+ *
+ * @param string $collection
+ * The name of the collection.
+ */
+ private function storeCollectionName(string $collection): void {
+ // We do not need to store the default collection.
+ if ($collection === StorageInterface::DEFAULT_COLLECTION) {
+ return;
+ }
+
+ $key_value = $this->getKeyValue($this->checkpoints->getActiveCheckpoint()->id, StorageInterface::DEFAULT_COLLECTION);
+ $collections = $key_value->get(static::CONFIG_COLLECTION_KEY, []);
+ assert(is_array($collections));
+ if (in_array($collection, $collections, TRUE)) {
+ return;
+ }
+ $collections[] = $collection;
+ $key_value->set(static::CONFIG_COLLECTION_KEY, $collections);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents(): array {
+ $events[ConfigEvents::SAVE][] = 'onConfigSaveAndDelete';
+ $events[ConfigEvents::DELETE][] = 'onConfigSaveAndDelete';
+ $events[ConfigEvents::RENAME][] = 'onConfigRename';
+ $events[ConfigCollectionEvents::SAVE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
+ $events[ConfigCollectionEvents::DELETE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
+ $events[ConfigCollectionEvents::RENAME_IN_COLLECTION][] = 'onConfigRename';
+ return $events;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write($name, array $data): never {
+ throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($name): never {
+ throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rename($name, $new_name): never {
+ throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll($prefix = ''): never {
+ throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php
new file mode 100644
index 00000000000..fe476ac9275
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Provides an interface for checkpoint storages.
+ *
+ * @internal
+ * This API is experimental.
+ */
+interface CheckpointStorageInterface extends StorageInterface {
+
+ /**
+ * Creates a checkpoint, if required, and returns the active checkpoint.
+ *
+ * If the storage determines that the current active checkpoint would contain
+ * the same information, it does not have to create a new checkpoint.
+ *
+ * @param string|\Stringable $label
+ * The checkpoint label to use if a new checkpoint is created.
+ *
+ * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+ * The currently active checkpoint.
+ */
+ public function checkpoint(string|\Stringable $label): Checkpoint;
+
+ /**
+ * Sets the checkpoint to read from.
+ *
+ * Calling read() or readMultiple() will return the configuration data at the
+ * time of the checkpoint that was set here. If none is set, then the
+ * configuration from the initial checkpoint will be returned.
+ *
+ * @param string|\Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint_id
+ * The checkpoint ID to read from.
+ *
+ * @return $this
+ *
+ * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+ * Thrown when the provided checkpoint does not exist.
+ */
+ public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php
new file mode 100644
index 00000000000..34047523d62
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php
@@ -0,0 +1,144 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\State\StateInterface;
+
+/**
+ * A chronological list of Checkpoint objects.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class LinearHistory implements CheckpointListInterface {
+
+ /**
+ * The store of all the checkpoint names in state.
+ */
+ private const CHECKPOINT_KEY = 'config.checkpoints';
+
+ /**
+ * The active checkpoint.
+ *
+ * In our implementation this is always the last in the list.
+ *
+ * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null
+ */
+ private ?Checkpoint $activeCheckpoint;
+
+ /**
+ * The list of checkpoints, keyed by ID.
+ *
+ * @var \Drupal\Core\Config\Checkpoint\Checkpoint[]
+ */
+ private array $checkpoints;
+
+ /**
+ * Constructs a checkpoints object.
+ *
+ * @param \Drupal\Core\State\StateInterface $state
+ * The state service.
+ * @param \Drupal\Component\Datetime\TimeInterface $time
+ * The time service.
+ */
+ public function __construct(
+ private readonly StateInterface $state,
+ private readonly TimeInterface $time,
+ ) {
+ $this->checkpoints = $this->state->get(self::CHECKPOINT_KEY, []);
+ $this->activeCheckpoint = end($this->checkpoints) ?: NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getActiveCheckpoint(): ?Checkpoint {
+ return $this->activeCheckpoint;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get(string $id): Checkpoint {
+ if (!isset($this->checkpoints[$id])) {
+ throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id));
+ }
+ return $this->checkpoints[$id];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getParents(string $id): \Traversable {
+ if (!isset($this->checkpoints[$id])) {
+ throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id));
+ }
+ $checkpoint = $this->checkpoints[$id];
+ while ($checkpoint->parent !== NULL) {
+ $checkpoint = $this->get($checkpoint->parent);
+ yield $checkpoint->id => $checkpoint;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getIterator(): \Traversable {
+ return new \ArrayIterator($this->checkpoints);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function count(): int {
+ return count($this->checkpoints);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function add(string $id, string|\Stringable $label): Checkpoint {
+ if (isset($this->checkpoints[$id])) {
+ throw new CheckpointExistsException(sprintf('Cannot create a checkpoint with the ID "%s" as it already exists', $id));
+ }
+ $checkpoint = new Checkpoint($id, $label, $this->time->getCurrentTime(), $this->activeCheckpoint?->id);
+ $this->checkpoints[$checkpoint->id] = $checkpoint;
+ $this->activeCheckpoint = $checkpoint;
+ $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints);
+
+ return $checkpoint;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete(string $id): static {
+ if (!isset($this->checkpoints[$id])) {
+ throw new UnknownCheckpointException(sprintf('Cannot delete a checkpoint with the ID "%s" as it does not exist', $id));
+ }
+
+ foreach ($this->checkpoints as $key => $checkpoint) {
+ unset($this->checkpoints[$key]);
+ if ($checkpoint->id === $id) {
+ break;
+ }
+ }
+ $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll(): static {
+ $this->checkpoints = [];
+ $this->activeCheckpoint = NULL;
+ $this->state->delete(self::CHECKPOINT_KEY);
+ return $this;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php
new file mode 100644
index 00000000000..f0822b6210c
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when using the checkpoint storage with no checkpoints.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class NoCheckpointsException extends \RuntimeException {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $message = 'This storage cannot be read because there are no checkpoints';
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php
new file mode 100644
index 00000000000..0f99855822c
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when trying to access a checkpoint that does not exist.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class UnknownCheckpointException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
index 0718c0818cb..73bba09b1d8 100644
--- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
+++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
@@ -4,6 +4,7 @@ namespace Drupal\Core\Config\Entity;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Schema\SchemaIncompleteException;
use Drupal\Core\Entity\EntityBase;
use Drupal\Core\Config\ConfigDuplicateUUIDException;
@@ -12,6 +13,7 @@ use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\Core\Entity\SynchronizableEntityTrait;
use Drupal\Core\Plugin\PluginDependencyTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a base configuration entity class.
@@ -503,6 +505,7 @@ abstract class ConfigEntityBase extends EntityBase implements ConfigEntityInterf
/**
* {@inheritdoc}
*/
+ #[ActionMethod(adminLabel: new TranslatableMarkup('Set third-party setting'))]
public function setThirdPartySetting($module, $key, $value) {
$this->third_party_settings[$module][$key] = $value;
return $this;
diff --git a/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php
new file mode 100644
index 00000000000..ac353632ab5
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Core\Access\AccessException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Session\AccountSwitcherInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class AdminAccountSwitcher implements AccountSwitcherInterface {
+
+ public function __construct(
+ private readonly AccountSwitcherInterface $decorated,
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ private readonly bool $isSuperUserAccessEnabled,
+ ) {}
+
+ /**
+ * Switches to an administrative account.
+ *
+ * This will switch to the first available account with a role that has the
+ * `is_admin` flag. If there are no such roles, or no such users, this will
+ * try to switch to user 1 if superuser access is enabled.
+ *
+ * @return \Drupal\Core\Session\AccountInterface
+ * The account that was switched to.
+ *
+ * @throws \Drupal\Core\Access\AccessException
+ * Thrown if there are no users with administrative roles.
+ */
+ public function switchToAdministrator(): AccountInterface {
+ $admin_roles = $this->entityTypeManager->getStorage('user_role')
+ ->getQuery()
+ ->condition('is_admin', TRUE)
+ ->execute();
+
+ $user_storage = $this->entityTypeManager->getStorage('user');
+
+ if ($admin_roles) {
+ $accounts = $user_storage->getQuery()
+ ->accessCheck(FALSE)
+ ->condition('roles', $admin_roles, 'IN')
+ ->condition('status', 1)
+ ->sort('uid')
+ ->range(0, 1)
+ ->execute();
+ }
+ else {
+ $accounts = [];
+ }
+ $account = $user_storage->load(reset($accounts) ?: 1);
+ assert($account instanceof AccountInterface);
+
+ if (array_intersect($account->getRoles(), $admin_roles) || ((int) $account->id() === 1 && $this->isSuperUserAccessEnabled)) {
+ $this->switchTo($account);
+ return $account;
+ }
+ throw new AccessException("There are no user accounts with administrative roles.");
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function switchTo(AccountInterface $account): AccountSwitcherInterface {
+ $this->decorated->switchTo($account);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function switchBack(): AccountSwitcherInterface {
+ $this->decorated->switchBack();
+ return $this;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Existing.php b/core/lib/Drupal/Core/DefaultContent/Existing.php
new file mode 100644
index 00000000000..75c5d8fff01
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Existing.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+/**
+ * Defines what to do if importing an entity that already exists (by UUID).
+ *
+ * @internal
+ * This API is experimental.
+ */
+enum Existing {
+
+ case Error;
+ case Skip;
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Finder.php b/core/lib/Drupal/Core/DefaultContent/Finder.php
new file mode 100644
index 00000000000..fb35891f6ff
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Finder.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Component\Graph\Graph;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Component\Utility\SortArray;
+use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
+use Symfony\Component\Finder\Finder as SymfonyFinder;
+
+/**
+ * Finds all default content in a directory, in dependency order.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class Finder {
+
+ /**
+ * The content entity data to import, in dependency order, keyed by entity UUID.
+ *
+ * @var array<string, array<mixed>>
+ */
+ public readonly array $data;
+
+ public function __construct(string $path) {
+ try {
+ // Scan for all YAML files in the content directory.
+ $finder = SymfonyFinder::create()
+ ->in($path)
+ ->files()
+ ->name('*.yml');
+ }
+ catch (DirectoryNotFoundException) {
+ $this->data = [];
+ return;
+ }
+
+ $graph = $files = [];
+ /** @var \Symfony\Component\Finder\SplFileInfo $file */
+ foreach ($finder as $file) {
+ /** @var array{_meta: array{uuid: string|null, depends: array<string, string>|null}} $decoded */
+ $decoded = Yaml::decode($file->getContents());
+ $decoded['_meta']['path'] = $file->getPathname();
+ $uuid = $decoded['_meta']['uuid'] ?? throw new ImportException($decoded['_meta']['path'] . ' does not have a UUID.');
+ $files[$uuid] = $decoded;
+
+ // For the graph to work correctly, every entity must be mentioned in it.
+ // This is inspired by
+ // \Drupal\Core\Config\Entity\ConfigDependencyManager::getGraph().
+ $graph += [
+ $uuid => [
+ 'edges' => [],
+ 'uuid' => $uuid,
+ ],
+ ];
+
+ foreach ($decoded['_meta']['depends'] ?? [] as $dependency_uuid => $entity_type) {
+ $graph[$dependency_uuid]['edges'][$uuid] = TRUE;
+ $graph[$dependency_uuid]['uuid'] = $dependency_uuid;
+ }
+ }
+ ksort($graph);
+
+ // Sort the dependency graph. The entities that are dependencies of other
+ // entities should come first.
+ $graph_object = new Graph($graph);
+ $sorted = $graph_object->searchAndSort();
+ uasort($sorted, SortArray::sortByWeightElement(...));
+
+ $entities = [];
+ foreach ($sorted as ['uuid' => $uuid]) {
+ if (array_key_exists($uuid, $files)) {
+ $entities[$uuid] = $files[$uuid];
+ }
+ }
+ $this->data = $entities;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/ImportException.php b/core/lib/Drupal/Core/DefaultContent/ImportException.php
new file mode 100644
index 00000000000..873b796bd2f
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/ImportException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+/**
+ * Exception thrown when there is an error importing content.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class ImportException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Importer.php b/core/lib/Drupal/Core/DefaultContent/Importer.php
new file mode 100644
index 00000000000..21814ce3648
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Importer.php
@@ -0,0 +1,378 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Plugin\DataType\EntityReference;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Installer\InstallerKernel;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\file\FileInterface;
+use Drupal\link\Plugin\Field\FieldType\LinkItem;
+use Drupal\user\EntityOwnerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+
+/**
+ * A service for handling import of content.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class Importer implements LoggerAwareInterface {
+
+ use LoggerAwareTrait;
+
+ /**
+ * The dependencies of the currently importing entity, if any.
+ *
+ * The keys are the UUIDs of the dependencies, and the values are arrays with
+ * two members: the entity type ID of the dependency, and the UUID to load.
+ *
+ * @var array<string, string[]>|null
+ */
+ private ?array $dependencies = NULL;
+
+ public function __construct(
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ private readonly AdminAccountSwitcher $accountSwitcher,
+ private readonly FileSystemInterface $fileSystem,
+ private readonly LanguageManagerInterface $languageManager,
+ private readonly EntityRepositoryInterface $entityRepository,
+ ) {}
+
+ /**
+ * Imports content entities from disk.
+ *
+ * @param \Drupal\Core\DefaultContent\Finder $content
+ * The content finder, which has information on the entities to create
+ * in the necessary dependency order.
+ * @param \Drupal\Core\DefaultContent\Existing $existing
+ * (optional) What to do if one of the entities being imported already
+ * exists, by UUID:
+ * - \Drupal\Core\DefaultContent\Existing::Error: Throw an exception.
+ * - \Drupal\Core\DefaultContent\Existing::Skip: Leave the existing entity
+ * as-is.
+ *
+ * @throws \Drupal\Core\DefaultContent\ImportException
+ * - If any of the entities being imported are not content entities.
+ * - If any of the entities being imported already exists, by UUID, and
+ * $existing is \Drupal\Core\DefaultContent\Existing::Error.
+ */
+ public function importContent(Finder $content, Existing $existing = Existing::Error): void {
+ if (count($content->data) === 0) {
+ return;
+ }
+
+ $account = $this->accountSwitcher->switchToAdministrator();
+
+ try {
+ /** @var array{_meta: array<mixed>} $decoded */
+ foreach ($content->data as $decoded) {
+ ['uuid' => $uuid, 'entity_type' => $entity_type_id, 'path' => $path] = $decoded['_meta'];
+ assert(is_string($uuid));
+ assert(is_string($entity_type_id));
+ assert(is_string($path));
+
+ $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+ /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
+ if (!$entity_type->entityClassImplements(ContentEntityInterface::class)) {
+ throw new ImportException("Content entity $uuid is a '$entity_type_id', which is not a content entity type.");
+ }
+
+ $entity = $this->entityRepository->loadEntityByUuid($entity_type_id, $uuid);
+ if ($entity) {
+ if ($existing === Existing::Skip) {
+ continue;
+ }
+ else {
+ throw new ImportException("$entity_type_id $uuid already exists.");
+ }
+ }
+
+ $entity = $this->toEntity($decoded)->enforceIsNew();
+
+ // Ensure that the entity is not owned by the anonymous user.
+ if ($entity instanceof EntityOwnerInterface && empty($entity->getOwnerId())) {
+ $entity->setOwnerId($account->id());
+ }
+
+ // If a file exists in the same folder, copy it to the designated
+ // target URI.
+ if ($entity instanceof FileInterface) {
+ $this->copyFileAssociatedWithEntity(dirname($path), $entity);
+ }
+ $violations = $entity->validate();
+ if (count($violations) > 0) {
+ throw new InvalidEntityException($violations, $path);
+ }
+ $entity->save();
+ }
+ }
+ finally {
+ $this->accountSwitcher->switchBack();
+ }
+ }
+
+ /**
+ * Copies a file from default content directory to the site's file system.
+ *
+ * @param string $path
+ * The path to the file to copy.
+ * @param \Drupal\file\FileInterface $entity
+ * The file entity.
+ */
+ private function copyFileAssociatedWithEntity(string $path, FileInterface &$entity): void {
+ $destination = $entity->getFileUri();
+ assert(is_string($destination));
+
+ // If the source file doesn't exist, there's nothing we can do.
+ $source = $path . '/' . basename($destination);
+ if (!file_exists($source)) {
+ $this->logger?->warning("File entity %name was imported, but the associated file (@path) was not found.", [
+ '%name' => $entity->label(),
+ '@path' => $source,
+ ]);
+ return;
+ }
+
+ $copy_file = TRUE;
+ if (file_exists($destination)) {
+ $source_hash = hash_file('sha256', $source);
+ assert(is_string($source_hash));
+ $destination_hash = hash_file('sha256', $destination);
+ assert(is_string($destination_hash));
+
+ if (hash_equals($source_hash, $destination_hash) && $this->entityTypeManager->getStorage('file')->loadByProperties(['uri' => $destination]) === []) {
+ // If the file hashes match and the file is not already a managed file
+ // then do not copy a new version to the file system. This prevents
+ // re-installs during development from creating unnecessary duplicates.
+ $copy_file = FALSE;
+ }
+ }
+
+ $target_directory = dirname($destination);
+ $this->fileSystem->prepareDirectory($target_directory, FileSystemInterface::CREATE_DIRECTORY);
+ if ($copy_file) {
+ $uri = $this->fileSystem->copy($source, $destination);
+ $entity->setFileUri($uri);
+ }
+ }
+
+ /**
+ * Converts an array of content entity data to a content entity object.
+ *
+ * @param array<string, array<mixed>> $data
+ * The entity data.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityInterface
+ * The unsaved entity.
+ *
+ * @throws \Drupal\Core\DefaultContent\ImportException
+ * If the `entity_type` or `uuid` meta keys are not set.
+ */
+ private function toEntity(array $data): ContentEntityInterface {
+ if (empty($data['_meta']['entity_type'])) {
+ throw new ImportException('The entity type metadata must be specified.');
+ }
+ if (empty($data['_meta']['uuid'])) {
+ throw new ImportException('The uuid metadata must be specified.');
+ }
+
+ $is_root = FALSE;
+ // @see ::loadEntityDependency()
+ if ($this->dependencies === NULL && !empty($data['_meta']['depends'])) {
+ $is_root = TRUE;
+ foreach ($data['_meta']['depends'] as $uuid => $entity_type) {
+ assert(is_string($uuid));
+ assert(is_string($entity_type));
+ $this->dependencies[$uuid] = [$entity_type, $uuid];
+ }
+ }
+
+ ['entity_type' => $entity_type] = $data['_meta'];
+ assert(is_string($entity_type));
+ /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
+ $entity_type = $this->entityTypeManager->getDefinition($entity_type);
+
+ $values = [
+ 'uuid' => $data['_meta']['uuid'],
+ ];
+ if (!empty($data['_meta']['bundle'])) {
+ $values[$entity_type->getKey('bundle')] = $data['_meta']['bundle'];
+ }
+
+ if (!empty($data['_meta']['default_langcode'])) {
+ $data = $this->verifyNormalizedLanguage($data);
+ $values[$entity_type->getKey('langcode')] = $data['_meta']['default_langcode'];
+ }
+
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ $entity = $this->entityTypeManager->getStorage($entity_type->id())->create($values);
+ foreach ($data['default'] as $field_name => $values) {
+ $this->setFieldValues($entity, $field_name, $values);
+ }
+
+ foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
+ if ($this->languageManager->getLanguage($langcode)) {
+ $translation = $entity->addTranslation($langcode, $entity->toArray());
+ foreach ($translation_data as $field_name => $values) {
+ $this->setFieldValues($translation, $field_name, $values);
+ }
+ }
+ }
+
+ if ($is_root) {
+ $this->dependencies = NULL;
+ }
+ return $entity;
+ }
+
+ /**
+ * Sets field values based on the normalized data.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The content entity.
+ * @param string $field_name
+ * The name of the field.
+ * @param array $values
+ * The normalized data for the field.
+ */
+ private function setFieldValues(ContentEntityInterface $entity, string $field_name, array $values): void {
+ foreach ($values as $delta => $item_value) {
+ if (!$entity->get($field_name)->get($delta)) {
+ $entity->get($field_name)->appendItem();
+ }
+ /** @var \Drupal\Core\Field\FieldItemInterface $item */
+ $item = $entity->get($field_name)->get($delta);
+
+ // Update the URI based on the target UUID for link fields.
+ if (isset($item_value['target_uuid']) && $item instanceof LinkItem) {
+ $target_entity = $this->loadEntityDependency($item_value['target_uuid']);
+ if ($target_entity) {
+ $item_value['uri'] = 'entity:' . $target_entity->getEntityTypeId() . '/' . $target_entity->id();
+ }
+ unset($item_value['target_uuid']);
+ }
+
+ $serialized_property_names = $this->getCustomSerializedPropertyNames($item);
+ foreach ($item_value as $property_name => $value) {
+ if (\in_array($property_name, $serialized_property_names)) {
+ if (\is_string($value)) {
+ throw new ImportException("Received string for serialized property $field_name.$delta.$property_name");
+ }
+ $value = serialize($value);
+ }
+
+ $property = $item->get($property_name);
+
+ if ($property instanceof EntityReference) {
+ if (is_array($value)) {
+ $value = $this->toEntity($value);
+ }
+ else {
+ $value = $this->loadEntityDependency($value);
+ }
+ }
+ $property->setValue($value);
+ }
+ }
+ }
+
+ /**
+ * Gets the names of all properties the plugin treats as serialized data.
+ *
+ * This allows the field storage definition or entity type to provide a
+ * setting for serialized properties. This can be used for fields that
+ * handle serialized data themselves and do not rely on the serialized schema
+ * flag.
+ *
+ * @param \Drupal\Core\Field\FieldItemInterface $field_item
+ * The field item.
+ *
+ * @return string[]
+ * The property names for serialized properties.
+ *
+ * @see \Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait::getCustomSerializedPropertyNames
+ */
+ private function getCustomSerializedPropertyNames(FieldItemInterface $field_item): array {
+ if ($field_item instanceof PluginInspectionInterface) {
+ $definition = $field_item->getPluginDefinition();
+ $serialized_fields = $field_item->getEntity()->getEntityType()->get('serialized_field_property_names');
+ $field_name = $field_item->getFieldDefinition()->getName();
+ if (is_array($serialized_fields) && isset($serialized_fields[$field_name]) && is_array($serialized_fields[$field_name])) {
+ return $serialized_fields[$field_name];
+ }
+ if (isset($definition['serialized_property_names']) && is_array($definition['serialized_property_names'])) {
+ return $definition['serialized_property_names'];
+ }
+ }
+ return [];
+ }
+
+ /**
+ * Loads the entity dependency by its UUID.
+ *
+ * @param string $target_uuid
+ * The entity UUID.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityInterface|null
+ * The loaded entity.
+ */
+ private function loadEntityDependency(string $target_uuid): ?ContentEntityInterface {
+ if ($this->dependencies && array_key_exists($target_uuid, $this->dependencies)) {
+ $entity = $this->entityRepository->loadEntityByUuid(...$this->dependencies[$target_uuid]);
+ assert($entity instanceof ContentEntityInterface || $entity === NULL);
+ return $entity;
+ }
+ return NULL;
+ }
+
+ /**
+ * Verifies that the site knows the default language of the normalized entity.
+ *
+ * Will attempt to switch to an alternative translation or just import it
+ * with the site default language.
+ *
+ * @param array $data
+ * The normalized entity data.
+ *
+ * @return array
+ * The normalized entity data, possibly with altered default language
+ * and translations.
+ */
+ private function verifyNormalizedLanguage(array $data): array {
+ $default_langcode = $data['_meta']['default_langcode'];
+ $default_language = $this->languageManager->getDefaultLanguage();
+ // Check the language. If the default language isn't known, import as one
+ // of the available translations if one exists with those values. If none
+ // exists, create the entity in the default language.
+ // During the installer, when installing with an alternative language,
+ // `en` is still the default when modules are installed so check the default language
+ // instead.
+ if (!$this->languageManager->getLanguage($default_langcode) || (InstallerKernel::installationAttempted() && $default_language->getId() !== $default_langcode)) {
+ $use_default = TRUE;
+ foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
+ if ($this->languageManager->getLanguage($langcode)) {
+ $data['_meta']['default_langcode'] = $langcode;
+ $data['default'] = \array_merge($data['default'], $translation_data);
+ unset($data['translations'][$langcode]);
+ $use_default = FALSE;
+ break;
+ }
+ }
+
+ if ($use_default) {
+ $data['_meta']['default_langcode'] = $default_language->getId();
+ }
+ }
+ return $data;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php
new file mode 100644
index 00000000000..412f35b09c2
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Symfony\Component\Validator\ConstraintViolationInterface;
+
+/**
+ * Thrown if an entity being imported has validation errors.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class InvalidEntityException extends \RuntimeException {
+
+ public function __construct(public readonly EntityConstraintViolationListInterface $violations, public readonly string $filePath) {
+ $messages = [];
+
+ foreach ($violations as $violation) {
+ assert($violation instanceof ConstraintViolationInterface);
+ $messages[] = $violation->getPropertyPath() . '=' . $violation->getMessage();
+ }
+ // Example: "/path/to/file.yml: field_a=Violation 1., field_b=Violation 2.".
+ parent::__construct("$filePath: " . implode('||', $messages));
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
index 48d70f818ed..20badf8734d 100644
--- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
+++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
@@ -2,10 +2,12 @@
namespace Drupal\Core\Entity;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Entity\Display\EntityDisplayInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Provides a common base class for entity view and form displays.
@@ -345,6 +347,7 @@ abstract class EntityDisplayBase extends ConfigEntityBase implements EntityDispl
/**
* {@inheritdoc}
*/
+ #[ActionMethod(adminLabel: new TranslatableMarkup('Add component to display'))]
public function setComponent($name, array $options = []) {
// If no weight specified, make sure the field sinks at the bottom.
if (!isset($options['weight'])) {
diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php
index 5b21717d9ea..645a42853ee 100644
--- a/core/lib/Drupal/Core/Field/FieldConfigBase.php
+++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php
@@ -2,10 +2,12 @@
namespace Drupal\Core\Field;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Base class for configurable field definitions.
@@ -327,6 +329,7 @@ abstract class FieldConfigBase extends ConfigEntityBase implements FieldConfigIn
/**
* {@inheritdoc}
*/
+ #[ActionMethod(adminLabel: new TranslatableMarkup('Change field label'))]
public function setLabel($label) {
$this->label = $label;
return $this;
diff --git a/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
new file mode 100644
index 00000000000..1a46e601e6c
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class ConfigConfigurator {
+
+ public readonly ?string $recipeConfigDirectory;
+
+ /**
+ * @param array $config
+ * Config options for a recipe.
+ * @param string $recipe_directory
+ * The path to the recipe.
+ * @param \Drupal\Core\Config\StorageInterface $active_configuration
+ * The active configuration storage.
+ */
+ public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) {
+ $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL;
+ $recipe_storage = $this->getConfigStorage();
+ foreach ($recipe_storage->listAll() as $config_name) {
+ if ($active_data = $active_configuration->read($config_name)) {
+ // @todo https://www.drupal.org/i/3439714 Investigate if there is any
+ // generic code in core for this.
+ unset($active_data['uuid'], $active_data['_core']);
+ if (empty($active_data['dependencies'])) {
+ unset($active_data['dependencies']);
+ }
+ $recipe_data = $recipe_storage->read($config_name);
+ if (empty($recipe_data['dependencies'])) {
+ unset($recipe_data['dependencies']);
+ }
+ // Ensure we don't get a false mismatch due to differing key order.
+ // @todo When https://www.drupal.org/project/drupal/issues/3230826 is
+ // fixed in core, use that API instead to sort the config data.
+ self::recursiveSortByKey($active_data);
+ self::recursiveSortByKey($recipe_data);
+ if ($active_data !== $recipe_data) {
+ throw new RecipePreExistingConfigException($config_name, sprintf("The configuration '%s' exists already and does not match the recipe's configuration", $config_name));
+ }
+ }
+ }
+ }
+
+ /**
+ * Sorts an array recursively, by key, alphabetically.
+ *
+ * @param mixed[] $data
+ * The array to sort, passed by reference.
+ *
+ * @todo Remove when https://www.drupal.org/project/drupal/issues/3230826 is
+ * fixed in core.
+ */
+ private static function recursiveSortByKey(array &$data): void {
+ // If the array is a list, it is by definition already sorted.
+ if (!array_is_list($data)) {
+ ksort($data);
+ }
+ foreach ($data as &$value) {
+ if (is_array($value)) {
+ self::recursiveSortByKey($value);
+ }
+ }
+ }
+
+ /**
+ * Gets a config storage object for reading config from the recipe.
+ *
+ * @return \Drupal\Core\Config\StorageInterface
+ * The config storage object for reading config from the recipe.
+ */
+ public function getConfigStorage(): StorageInterface {
+ $storages = [];
+
+ if ($this->recipeConfigDirectory) {
+ // Config provided by the recipe should take priority over config from
+ // extensions.
+ $storages[] = new FileStorage($this->recipeConfigDirectory);
+ }
+ if (!empty($this->config['import'])) {
+ /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */
+ $module_list = \Drupal::service('extension.list.module');
+ /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */
+ $theme_list = \Drupal::service('extension.list.theme');
+ foreach ($this->config['import'] as $extension => $config) {
+ // If the recipe explicitly does not want to import any config from this
+ // extension, skip it.
+ if ($config === NULL) {
+ continue;
+ }
+ $path = match (TRUE) {
+ $module_list->exists($extension) => $module_list->getPath($extension),
+ $theme_list->exists($extension) => $theme_list->getPath($extension),
+ default => throw new \RuntimeException("$extension is not a theme or module")
+ };
+ $config = $config === '*' ? [] : $config;
+ $storages[] = new RecipeExtensionConfigStorage($path, $config);
+ }
+ }
+
+ return RecipeConfigStorageWrapper::createStorageFromArray($storages);
+ }
+
+ /**
+ * Determines if the recipe has any config or config actions to apply.
+ *
+ * @return bool
+ * TRUE if the recipe has any config or config actions to apply, FALSE if
+ * not.
+ */
+ public function hasTasks(): bool {
+ return $this->recipeConfigDirectory !== NULL || count($this->config);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/InstallConfigurator.php b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
new file mode 100644
index 00000000000..abc51f5b269
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
@@ -0,0 +1,120 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Extension\Dependency;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class InstallConfigurator {
+
+ /**
+ * The list of modules to install.
+ *
+ * This list is sorted an includes any module dependencies of the provided
+ * extensions.
+ *
+ * @var string[]
+ */
+ public readonly array $modules;
+
+ /**
+ * The list of themes to install.
+ *
+ * This list is sorted an includes any theme dependencies of the provided
+ * extensions.
+ *
+ * @var string[]
+ */
+ public readonly array $themes;
+
+ /**
+ * @param string[] $extensions
+ * A list of extensions for a recipe to install.
+ * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
+ * The module list service.
+ * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list
+ * The theme list service.
+ */
+ public function __construct(array $extensions, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) {
+ assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
+ $extensions = array_map(fn($extension) => Dependency::createFromString($extension)->getName(), $extensions);
+ $extensions = array_combine($extensions, $extensions);
+ $module_data = $module_list->reset()->getList();
+ $theme_data = $theme_list->reset()->getList();
+
+ $modules = array_intersect_key($extensions, $module_data);
+ $themes = array_intersect_key($extensions, $theme_data);
+
+ $missing_extensions = array_diff($extensions, $modules, $themes);
+
+ // Add theme module dependencies.
+ foreach ($themes as $theme => $value) {
+ $modules = array_merge($modules, array_keys($theme_data[$theme]->module_dependencies));
+ }
+
+ // Add modules that other modules depend on.
+ foreach ($modules as $module) {
+ if ($module_data[$module]->requires) {
+ $modules = array_merge($modules, array_keys($module_data[$module]->requires));
+ }
+ }
+
+ // Remove all modules that have been installed already.
+ $modules = array_diff(array_unique($modules), array_keys($module_list->getAllInstalledInfo()));
+ $modules = array_combine($modules, $modules);
+
+ // Create a sortable list of modules.
+ foreach ($modules as $name => $value) {
+ if (isset($module_data[$name])) {
+ $modules[$name] = $module_data[$name]->sort;
+ }
+ else {
+ $missing_extensions[$name] = $name;
+ }
+ }
+
+ // Add any missing base themes to the list of themes to install.
+ foreach ($themes as $theme => $value) {
+ // $theme_data[$theme]->requires contains both theme and module
+ // dependencies keyed by the extension machine names.
+ // $theme_data[$theme]->module_dependencies contains only the module
+ // dependencies keyed by the module extension machine name. Therefore,
+ // we can find the theme dependencies by finding array keys for
+ // 'requires' that are not in $module_dependencies.
+ $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $theme_data[$theme]->module_dependencies);
+ $themes = array_merge($themes, array_keys($theme_dependencies));
+ }
+
+ // Remove all themes that have been installed already.
+ $themes = array_diff(array_unique($themes), array_keys($theme_list->getAllInstalledInfo()));
+ $themes = array_combine($themes, $themes);
+
+ // Create a sortable list of themes.
+ foreach ($themes as $name => $value) {
+ if (isset($theme_data[$name])) {
+ $themes[$name] = $theme_data[$name]->sort;
+ }
+ else {
+ $missing_extensions[$name] = $name;
+ }
+ }
+
+ if (!empty($missing_extensions)) {
+ throw new RecipeMissingExtensionsException(array_values($missing_extensions));
+ }
+
+ arsort($modules);
+ arsort($themes);
+ $this->modules = array_keys($modules);
+ $this->themes = array_keys($themes);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/InvalidConfigException.php b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php
new file mode 100644
index 00000000000..0c92a42f55c
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\Schema\Mapping;
+use Symfony\Component\Validator\ConstraintViolationList;
+
+/**
+ * Thrown if config created or changed by a recipe fails validation.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class InvalidConfigException extends \RuntimeException {
+
+ /**
+ * Constructs an InvalidConfigException object.
+ *
+ * @param \Symfony\Component\Validator\ConstraintViolationList $violations
+ * The validation constraint violations.
+ * @param \Drupal\Core\Config\Schema\Mapping $data
+ * A typed data wrapper around the invalid config data.
+ * @param string $message
+ * (optional) The exception message. Defaults to the string representation
+ * of the constraint violation list.
+ * @param int $code
+ * (optional) The exception code. Defaults to 0.
+ * @param \Throwable|null $previous
+ * (optional) The previous exception, if any.
+ */
+ public function __construct(
+ public readonly ConstraintViolationList $violations,
+ public readonly Mapping $data,
+ string $message = '',
+ int $code = 0,
+ ?\Throwable $previous = NULL,
+ ) {
+ parent::__construct($message ?: $this->formatMessage(), $code, $previous);
+ }
+
+ /**
+ * Formats the constraint violation list as a human-readable message.
+ *
+ * @return string
+ * The formatted message.
+ */
+ private function formatMessage(): string {
+ $lines = [
+ sprintf('There were validation errors in %s:', $this->data->getName()),
+ ];
+ /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
+ foreach ($this->violations as $violation) {
+ $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage());
+ }
+ return implode("\n", $lines);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php
new file mode 100644
index 00000000000..4da07d2743e
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/Recipe.php
@@ -0,0 +1,301 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\Extension\Dependency;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint;
+use Symfony\Component\Validator\Constraints\All;
+use Symfony\Component\Validator\Constraints\AtLeastOneOf;
+use Symfony\Component\Validator\Constraints\Callback;
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\IdenticalTo;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotIdenticalTo;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Regex;
+use Symfony\Component\Validator\Constraints\Required;
+use Symfony\Component\Validator\Constraints\Sequentially;
+use Symfony\Component\Validator\Constraints\Type;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+use Symfony\Component\Validator\Validation;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class Recipe {
+
+ const COMPOSER_PROJECT_TYPE = 'drupal-recipe';
+
+ public function __construct(
+ public readonly string $name,
+ public readonly string $description,
+ public readonly string $type,
+ public readonly RecipeConfigurator $recipes,
+ public readonly InstallConfigurator $install,
+ public readonly ConfigConfigurator $config,
+ public readonly Finder $content,
+ public readonly string $path,
+ ) {
+ }
+
+ /**
+ * Creates a recipe object from the provided path.
+ *
+ * @param string $path
+ * The path to a recipe.
+ *
+ * @return static
+ * The Recipe object.
+ */
+ public static function createFromDirectory(string $path): static {
+ $recipe_data = self::parse($path . '/recipe.yml');
+
+ $recipe_discovery = static::getRecipeDiscovery(dirname($path));
+ $recipes = new RecipeConfigurator(is_array($recipe_data['recipes']) ? $recipe_data['recipes'] : [], $recipe_discovery);
+ $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme'));
+ $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage'));
+ $content = new Finder($path . '/content');
+ return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content, $path);
+ }
+
+ /**
+ * Parses and validates a recipe.yml file.
+ *
+ * @param string $file
+ * The path of a recipe.yml file.
+ *
+ * @return mixed[]
+ * The parsed and validated data from the file.
+ *
+ * @throws \Drupal\Core\Recipe\RecipeFileException
+ * Thrown if the recipe.yml file is unreadable, invalid, or cannot be
+ * validated.
+ */
+ private static function parse(string $file): array {
+ if (!file_exists($file)) {
+ throw new RecipeFileException($file, "There is no $file file");
+ }
+ $recipe_contents = file_get_contents($file);
+ if (!$recipe_contents) {
+ throw new RecipeFileException($file, "$file does not exist or could not be read.");
+ }
+ // Certain parts of our validation need to be able to scan for other
+ // recipes.
+ // @see ::validateRecipeExists()
+ // @see ::validateConfigActions()
+ $discovery = self::getRecipeDiscovery(dirname($file, 2));
+
+ $constraints = new Collection([
+ 'name' => new Required([
+ new Type('string'),
+ new NotBlank(),
+ // Matching `type: label` in core.data_types.schema.yml.
+ new RegexConstraint(
+ pattern: '/([^\PC])/u',
+ message: 'Recipe names cannot span multiple lines or contain control characters.',
+ match: FALSE,
+ ),
+ ]),
+ 'description' => new Optional([
+ new NotBlank(),
+ // Matching `type: text` in core.data_types.schema.yml.
+ new RegexConstraint(
+ pattern: '/([^\PC\x09\x0a\x0d])/u',
+ message: 'The recipe description cannot contain control characters, only visible characters.',
+ match: FALSE,
+ ),
+ ]),
+ 'type' => new Optional([
+ new Type('string'),
+ new NotBlank(),
+ // Matching `type: label` in core.data_types.schema.yml.
+ new RegexConstraint(
+ pattern: '/([^\PC])/u',
+ message: 'Recipe type cannot span multiple lines or contain control characters.',
+ match: FALSE,
+ ),
+ ]),
+ 'recipes' => new Optional([
+ new All([
+ new Type('string'),
+ new NotBlank(),
+ // If recipe depends on itself, ::validateRecipeExists() will set off
+ // an infinite loop. We can avoid that by skipping that validation if
+ // the recipe depends on itself, which is what Sequentially does.
+ new Sequentially([
+ new NotIdenticalTo(
+ value: basename(dirname($file)),
+ message: 'The {{ compared_value }} recipe cannot depend on itself.',
+ ),
+ new Callback(
+ callback: self::validateRecipeExists(...),
+ payload: $discovery,
+ ),
+ ]),
+ ]),
+ ]),
+ // @todo https://www.drupal.org/i/3424603 Validate the corresponding
+ // import.
+ 'install' => new Optional([
+ new All([
+ new Type('string'),
+ new Sequentially([
+ new NotBlank(),
+ new Callback(self::validateExtensionIsAvailable(...)),
+ ]),
+ ]),
+ ]),
+ 'config' => new Optional([
+ new Collection([
+ // Each entry in the `import` list can either be `*` (import all of
+ // the extension's config), or a list of config names to import from
+ // the extension.
+ // @todo https://www.drupal.org/i/3439716 Validate config file name,
+ // if given.
+ 'import' => new Optional([
+ new All([
+ new AtLeastOneOf([
+ new IdenticalTo('*'),
+ new All([
+ new Type('string'),
+ new NotBlank(),
+ new Regex('/^.+\./'),
+ ]),
+ ]),
+ ]),
+ ]),
+ 'actions' => new Optional([
+ new All([
+ new Type('array'),
+ new NotBlank(),
+ new Callback(
+ callback: self::validateConfigActions(...),
+ payload: $discovery,
+ ),
+ ]),
+ ]),
+ ]),
+ ]),
+ 'content' => new Optional([
+ new Type('array'),
+ ]),
+ ]);
+
+ $recipe_data = Yaml::decode($recipe_contents);
+ /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+ $violations = Validation::createValidator()->validate($recipe_data, $constraints);
+ if (count($violations) > 0) {
+ throw RecipeFileException::fromViolationList($file, $violations);
+ }
+ $recipe_data += [
+ 'description' => '',
+ 'type' => '',
+ 'recipes' => [],
+ 'install' => [],
+ 'config' => [],
+ 'content' => [],
+ ];
+ return $recipe_data;
+ }
+
+ /**
+ * Validates that the value is an available module/theme (installed or not).
+ *
+ * @param string $value
+ * The value to validate.
+ * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+ * The validator execution context.
+ *
+ * @see \Drupal\Core\Extension\ExtensionList::getAllAvailableInfo()
+ */
+ private static function validateExtensionIsAvailable(string $value, ExecutionContextInterface $context): void {
+ $name = Dependency::createFromString($value)->getName();
+ $all_available = \Drupal::service(ModuleExtensionList::class)->getAllAvailableInfo() + \Drupal::service(ThemeExtensionList::class)->getAllAvailableInfo();
+ if (!array_key_exists($name, $all_available)) {
+ $context->addViolation('"%extension" is not a known module or theme.', [
+ '%extension' => $name,
+ ]);
+ }
+ }
+
+ /**
+ * Validates that a recipe exists.
+ *
+ * @param string $name
+ * The machine name of the recipe to look for.
+ * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+ * The validator execution context.
+ * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery
+ * A discovery object to find other recipes.
+ */
+ private static function validateRecipeExists(string $name, ExecutionContextInterface $context, RecipeDiscovery $discovery): void {
+ if (empty($name)) {
+ return;
+ }
+ try {
+ $discovery->getRecipe($name);
+ }
+ catch (UnknownRecipeException) {
+ $context->addViolation('The %name recipe does not exist.', ['%name' => $name]);
+ }
+ }
+
+ /**
+ * Gets the recipe discovery object for a recipe.
+ *
+ * @param string $recipeDirectory
+ * The directory the contains the recipe.
+ *
+ * @return \Drupal\Core\Recipe\RecipeDiscovery
+ */
+ private static function getRecipeDiscovery(string $recipeDirectory): RecipeDiscovery {
+ return new RecipeDiscovery($recipeDirectory);
+ }
+
+ /**
+ * Validates that the corresponding extension is enabled for a config action.
+ *
+ * @param mixed $value
+ * The config action; not used.
+ * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+ * The validator execution context.
+ * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery
+ * A discovery object to find other recipes.
+ */
+ private static function validateConfigActions(mixed $value, ExecutionContextInterface $context, RecipeDiscovery $discovery): void {
+ $config_name = str_replace(['[config][actions]', '[', ']'], '', $context->getPropertyPath());
+ [$config_provider] = explode('.', $config_name);
+ if ($config_provider === 'core') {
+ return;
+ }
+
+ $recipe_being_validated = $context->getRoot();
+ assert(is_array($recipe_being_validated));
+
+ $configurator = new RecipeConfigurator($recipe_being_validated['recipes'] ?? [], $discovery);
+
+ // The config provider must either be an already-installed module or theme,
+ // or an extension being installed by this recipe or a recipe it depends on.
+ $all_extensions = [
+ ...array_keys(\Drupal::service('extension.list.module')->getAllInstalledInfo()),
+ ...array_keys(\Drupal::service('extension.list.theme')->getAllInstalledInfo()),
+ ...$recipe_being_validated['install'] ?? [],
+ ...$configurator->listAllExtensions(),
+ ];
+
+ if (!in_array($config_provider, $all_extensions, TRUE)) {
+ $context->addViolation('Config actions cannot be applied to %config_name because the %config_provider extension is not installed, and is not installed by this recipe or any of the recipes it depends on.', [
+ '%config_name' => $config_name,
+ '%config_provider' => $config_provider,
+ ]);
+ }
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php
new file mode 100644
index 00000000000..3e3078d23ca
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event dispatched after a recipe has been applied.
+ *
+ * Subscribers to this event should avoid modifying config or content, because
+ * it is very likely that the recipe was applied as part of a chain of recipes,
+ * so config and content are probably about to change again. This event is best
+ * used for tasks like notifications, logging or updating a value in state.
+ */
+final class RecipeAppliedEvent extends Event {
+
+ /**
+ * Constructs a RecipeAppliedEvent object.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe that was applied.
+ */
+ public function __construct(public readonly Recipe $recipe) {
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
new file mode 100644
index 00000000000..84ee9e2d654
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
@@ -0,0 +1,216 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\ConfigImporter;
+use Drupal\Core\Config\ConfigImporterException;
+use Drupal\Core\Config\StorageComparer;
+use Drupal\Core\DrupalKernel;
+use Drupal\Core\Site\Settings;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LogLevel;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Logger\ConsoleLogger;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Applies recipe.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeCommand extends Command {
+
+ /**
+ * The class loader.
+ *
+ * @var object
+ */
+ protected $classLoader;
+
+ /**
+ * Constructs a new RecipeCommand command.
+ *
+ * @param object $class_loader
+ * The class loader.
+ */
+ public function __construct($class_loader) {
+ parent::__construct('recipe');
+ $this->classLoader = $class_loader;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(): void {
+ $this
+ ->setDescription('Applies a recipe to a site.')
+ ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder to apply');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $io = new SymfonyStyle($input, $output);
+
+ $recipe_path = $input->getArgument('path');
+ if (!is_string($recipe_path) || !is_dir($recipe_path)) {
+ $io->error(sprintf('The supplied path %s is not a directory', $recipe_path));
+ }
+ // Recipes can only be applied to an already-installed site.
+ $container = $this->boot()->getContainer();
+
+ /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */
+ $checkpoint_storage = $container->get('config.storage.checkpoint');
+ $recipe = Recipe::createFromDirectory($recipe_path);
+ if ($checkpoint_storage instanceof LoggerAwareInterface) {
+ $logger = new ConsoleLogger($output, [
+ // The checkpoint storage logs a notice if it decides to not create a
+ // checkpoint, and we want to be sure those notices are seen even
+ // without additional verbosity.
+ LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL,
+ ]);
+ $checkpoint_storage->setLogger($logger);
+ }
+ $backup_checkpoint = $checkpoint_storage
+ ->checkpoint("Backup before the '$recipe->name' recipe.");
+ try {
+ $steps = RecipeRunner::toBatchOperations($recipe);
+ $progress_bar = $io->createProgressBar();
+ $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n");
+ $progress_bar->setMessage($this->toPlainString(t('Applying recipe')));
+ $progress_bar->start(count($steps));
+
+ /** @var array{message?: \Stringable|string, results: array{module?: string[], theme?: string[], content?: string[], recipe?: string[]}} $context */
+ $context = ['results' => []];
+ foreach ($steps as $step) {
+ call_user_func_array($step[0], array_merge($step[1], [&$context]));
+ if (isset($context['message'])) {
+ $progress_bar->setMessage($this->toPlainString($context['message']));
+ }
+ unset($context['message']);
+ $progress_bar->advance();
+ }
+ if ($io->isVerbose()) {
+ if (!empty($context['results']['module'])) {
+ $io->section($this->toPlainString(t('Modules installed')));
+ $modules = array_map(fn ($module) => \Drupal::service('extension.list.module')->getName($module), $context['results']['module']);
+ sort($modules, SORT_NATURAL);
+ $io->listing($modules);
+ }
+ if (!empty($context['results']['theme'])) {
+ $io->section($this->toPlainString(t('Themes installed')));
+ $themes = array_map(fn ($theme) => \Drupal::service('extension.list.theme')->getName($theme), $context['results']['theme']);
+ sort($themes, SORT_NATURAL);
+ $io->listing($themes);
+ }
+ if (!empty($context['results']['content'])) {
+ $io->section($this->toPlainString(t('Content created for recipes')));
+ $io->listing($context['results']['content']);
+ }
+ if (!empty($context['results']['recipe'])) {
+ $io->section($this->toPlainString(t('Recipes applied')));
+ $io->listing($context['results']['recipe']);
+ }
+ }
+ $io->success($this->toPlainString(t('%recipe applied successfully', ['%recipe' => $recipe->name])));
+ return 0;
+ }
+ catch (\Throwable $e) {
+ try {
+ $this->rollBackToCheckpoint($backup_checkpoint);
+ }
+ catch (ConfigImporterException $importer_exception) {
+ $io->error($importer_exception->getMessage());
+ }
+ throw $e;
+ }
+ }
+
+ /**
+ * Converts a stringable like TranslatableMarkup to a plain text string.
+ *
+ * @param \Stringable|string $text
+ * The string to convert.
+ *
+ * @return string
+ * The plain text string.
+ */
+ private function toPlainString(\Stringable|string $text): string {
+ return PlainTextOutput::renderFromHtml((string) $text);
+ }
+
+ /**
+ * Rolls config back to a particular checkpoint.
+ *
+ * @param \Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint
+ * The checkpoint to roll back to.
+ */
+ private function rollBackToCheckpoint(Checkpoint $checkpoint): void {
+ $container = \Drupal::getContainer();
+
+ /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */
+ $checkpoint_storage = $container->get('config.storage.checkpoint');
+ $checkpoint_storage->setCheckpointToReadFrom($checkpoint);
+
+ $storage_comparer = new StorageComparer($checkpoint_storage, $container->get('config.storage'));
+ $storage_comparer->reset();
+
+ $config_importer = new ConfigImporter(
+ $storage_comparer,
+ $container->get('event_dispatcher'),
+ $container->get('config.manager'),
+ $container->get('lock'),
+ $container->get('config.typed'),
+ $container->get('module_handler'),
+ $container->get('module_installer'),
+ $container->get('theme_handler'),
+ $container->get('string_translation'),
+ $container->get('extension.list.module'),
+ $container->get('extension.list.theme'),
+ );
+ $config_importer->import();
+ }
+
+ /**
+ * Boots up a Drupal environment.
+ *
+ * @return \Drupal\Core\DrupalKernelInterface
+ * The Drupal kernel.
+ *
+ * @throws \Exception
+ * Exception thrown if kernel does not boot.
+ */
+ protected function boot() {
+ $kernel = new DrupalKernel('prod', $this->classLoader, FALSE);
+ $kernel::bootEnvironment();
+ $kernel->setSitePath($this->getSitePath());
+ Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader);
+ $kernel->boot();
+ $kernel->preHandle(Request::createFromGlobals());
+ return $kernel;
+ }
+
+ /**
+ * Gets the site path.
+ *
+ * Defaults to 'sites/default'. For testing purposes this can be overridden
+ * using the DRUPAL_DEV_SITE_PATH environment variable.
+ *
+ * @return string
+ * The site path to use.
+ */
+ protected function getSitePath() {
+ return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
new file mode 100644
index 00000000000..db0e6e24b0b
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\ConfigInstaller;
+use Drupal\Core\Config\Entity\ConfigDependencyManager;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Installer\InstallerKernel;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint;
+
+/**
+ * Extends the ConfigInstaller service for recipes.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeConfigInstaller extends ConfigInstaller {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function installRecipeConfig(ConfigConfigurator $recipe_config): void {
+ $storage = $recipe_config->getConfigStorage();
+
+ // Build the list of new configuration to create.
+ $list = array_diff($storage->listAll(), $this->getActiveStorages()->listAll());
+
+ // If there is nothing to do.
+ if (empty($list)) {
+ return;
+ }
+
+ $config_to_create = $storage->readMultiple($list);
+
+ // Sort $config_to_create in the order of the least dependent first.
+ $dependency_manager = new ConfigDependencyManager();
+ $dependency_manager->setData($config_to_create);
+ $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create);
+
+ // Create the optional configuration if there is any left after filtering.
+ if (!empty($config_to_create)) {
+ $this->createConfiguration(StorageInterface::DEFAULT_COLLECTION, $config_to_create);
+ }
+
+ // Validation during the installer is hard. For example:
+ // Drupal\ckeditor5\Plugin\Validation\Constraint\EnabledConfigurablePluginsConstraintValidator
+ // ends up calling _ckeditor5_theme_css() via
+ // Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition->validateDrupalAspects()
+ // and this expects the theme system to be set up correctly but we're in the
+ // installer so this cannot happen.
+ // @todo https://www.drupal.org/i/3443603 consider adding a validation step
+ // for recipes to the installer via install_tasks().
+ if (InstallerKernel::installationAttempted()) {
+ return;
+ }
+
+ foreach (array_keys($config_to_create) as $name) {
+ // All config objects are mappings.
+ /** @var \Drupal\Core\Config\Schema\Mapping $typed_config */
+ $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData());
+ foreach ($typed_config->getConstraints() as $constraint) {
+ // Only validate the config if it has explicitly been marked as being
+ // validatable.
+ if ($constraint instanceof FullyValidatableConstraint) {
+ /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+ $violations = $typed_config->validate();
+ if (count($violations) > 0) {
+ throw new InvalidConfigException($violations, $typed_config);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
new file mode 100644
index 00000000000..9af54bfcb73
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\NullStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Merges two storages together.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeConfigStorageWrapper implements StorageInterface {
+
+ /**
+ * @param \Drupal\Core\Config\StorageInterface $storageA
+ * First config storage to wrap.
+ * @param \Drupal\Core\Config\StorageInterface $storageB
+ * Second config storage to wrap.
+ * @param string $collection
+ * (optional) The collection to store configuration in. Defaults to the
+ * default collection.
+ */
+ public function __construct(
+ protected readonly StorageInterface $storageA,
+ protected readonly StorageInterface $storageB,
+ protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+ ) {
+ }
+
+ /**
+ * Creates a single config storage for an array of storages.
+ *
+ * If the same configuration is contained in multiple storages then the
+ * version returned is from the first storage supplied in the $storages array.
+ *
+ * @param \Drupal\Core\Config\StorageInterface[] $storages
+ * An array of storages to merge into a single storage.
+ *
+ * @return \Drupal\Core\Config\StorageInterface
+ * A config storage that represents a merge of all the provided storages.
+ */
+ public static function createStorageFromArray(array $storages): StorageInterface {
+ // If storages is empty use the NullStorage to represent an empty storage.
+ if (empty($storages)) {
+ return new NullStorage();
+ }
+
+ // When there is only one storage there is no point wrapping it.
+ if (count($storages) === 1) {
+ return reset($storages);
+ }
+
+ // Reduce all the storages to a single RecipeConfigStorageWrapper object.
+ // The storages are prioritized in the order they are added to $storages.
+ return array_reduce($storages, fn(StorageInterface $carry, StorageInterface $storage) => new static($carry, $storage), new static(
+ array_shift($storages),
+ array_shift($storages)
+ ));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exists($name): bool {
+ return $this->storageA->exists($name) || $this->storageB->exists($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function read($name): array|bool {
+ return $this->storageA->read($name) ?: $this->storageB->read($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function readMultiple(array $names): array {
+ // If both storageA and storageB contain the same configuration, the value
+ // for storageA takes precedence.
+ return array_merge($this->storageB->readMultiple($names), $this->storageA->readMultiple($names));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write($name, array $data): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rename($name, $new_name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function encode($data): string {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decode($raw): array {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function listAll($prefix = ''): array {
+ return array_unique(array_merge($this->storageA->listAll($prefix), $this->storageB->listAll($prefix)));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll($prefix = ''): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createCollection($collection): static {
+ return new static(
+ $this->storageA->createCollection($collection),
+ $this->storageB->createCollection($collection),
+ $collection
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllCollectionNames(): array {
+ return array_unique(array_merge($this->storageA->getAllCollectionNames(), $this->storageB->getAllCollectionNames()));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCollectionName(): string {
+ return $this->collection;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
new file mode 100644
index 00000000000..927bcfc17ff
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeConfigurator {
+
+ public readonly array $recipes;
+
+ /**
+ * @param string[] $recipes
+ * A list of recipes for a recipe to apply. The recipes will be applied in
+ * the order listed.
+ * @param \Drupal\Core\Recipe\RecipeDiscovery $recipeDiscovery
+ * Recipe discovery.
+ */
+ public function __construct(array $recipes, RecipeDiscovery $recipeDiscovery) {
+ $this->recipes = array_map([$recipeDiscovery, 'getRecipe'], $recipes);
+ }
+
+ /**
+ * Returns all the recipes installed by this recipe.
+ *
+ * @return \Drupal\Core\Recipe\Recipe[]
+ * An array of all the recipes being installed.
+ */
+ private function listAllRecipes(): array {
+ $recipes = [];
+ foreach ($this->recipes as $recipe) {
+ $recipes[] = $recipe;
+ $recipes = array_merge($recipes, $recipe->recipes->listAllRecipes());
+ }
+ return array_values(array_unique($recipes, SORT_REGULAR));
+ }
+
+ /**
+ * List all the extensions installed by this recipe and its dependencies.
+ *
+ * @return string[]
+ * All the modules and themes that will be installed by the current
+ * recipe and all the recipes it depends on.
+ */
+ public function listAllExtensions(): array {
+ $extensions = [];
+ foreach ($this->listAllRecipes() as $recipe) {
+ $extensions = array_merge($extensions, $recipe->install->modules, $recipe->install->themes);
+ }
+ return array_values(array_unique($extensions));
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
new file mode 100644
index 00000000000..e27bcee3901
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeDiscovery {
+
+ /**
+ * Constructs a recipe discovery object.
+ *
+ * @param string $path
+ * The path will be searched folders containing a recipe.yml. There will be
+ * no traversal further into the directory structure.
+ */
+ public function __construct(protected string $path) {
+ }
+
+ /**
+ * Gets a recipe object.
+ *
+ * @param string $name
+ * The machine name of the recipe to find.
+ *
+ * @return \Drupal\Core\Recipe\Recipe
+ * The recipe object.
+ *
+ * @throws \Drupal\Core\Recipe\UnknownRecipeException
+ * Thrown when the recipe cannot be found.
+ */
+ public function getRecipe(string $name): Recipe {
+ // In order to allow recipes to include core provided recipes, $name can be
+ // a Drupal root relative path to a recipe folder. For example, a recipe can
+ // include the core provided 'article_tags' recipe by listing the recipe as
+ // 'core/recipes/article_tags'. It is strongly recommended not to rely on
+ // relative paths for including recipes. Required recipes should be put in
+ // the same parent directory as the recipe being applied. Note, only linux
+ // style directory separators are supported. PHP on Windows can resolve the
+ // mix of directory separators.
+ if (str_contains($name, '/')) {
+ $path = \Drupal::root() . "/$name/recipe.yml";
+ }
+ else {
+ $path = $this->path . "/$name/recipe.yml";
+ }
+
+ if (file_exists($path)) {
+ return Recipe::createFromDirectory(dirname($path));
+ }
+ $search_path = dirname($path, 2);
+ throw new UnknownRecipeException($name, $search_path, sprintf("Can not find the %s recipe, search path: %s", $name, $search_path));
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
new file mode 100644
index 00000000000..604b566d765
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
@@ -0,0 +1,146 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Allows the recipe to select configuration from the module.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeExtensionConfigStorage implements StorageInterface {
+
+ protected readonly StorageInterface $storage;
+
+ /**
+ * @param string $extensionPath
+ * The path extension to read configuration from
+ * @param array $configNames
+ * The list of config to read from the extension. An empty array means all
+ * configuration.
+ * @param string $collection
+ * (optional) The collection to store configuration in. Defaults to the
+ * default collection.
+ */
+ public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) {
+ $this->storage = new RecipeConfigStorageWrapper(
+ new FileStorage($this->extensionPath . '/config/install', $this->collection),
+ new FileStorage($this->extensionPath . '/config/optional', $this->collection),
+ $collection
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exists($name): bool {
+ if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
+ return FALSE;
+ }
+ return $this->storage->exists($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function read($name): array|bool {
+ if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
+ return FALSE;
+ }
+ return $this->storage->read($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function readMultiple(array $names): array {
+ if (!empty($this->configNames)) {
+ $names = array_intersect($this->configNames, $names);
+ }
+ return $this->storage->readMultiple($names);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write($name, array $data): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rename($name, $new_name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function encode($data): string {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decode($raw): array {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function listAll($prefix = ''): array {
+ $names = $this->storage->listAll($prefix);
+ if (!empty($this->configNames)) {
+ $names = array_intersect($this->configNames, $names);
+ }
+ return $names;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll($prefix = ''): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createCollection($collection): static {
+ return new static(
+ $this->extensionPath,
+ $this->configNames,
+ $collection
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllCollectionNames(): array {
+ return $this->storage->getAllCollectionNames();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCollectionName(): string {
+ return $this->collection;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeFileException.php b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
new file mode 100644
index 00000000000..d45cc7ba494
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Symfony\Component\Validator\ConstraintViolationList;
+
+/**
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeFileException extends \RuntimeException {
+
+ /**
+ * Constructs a RecipeFileException object.
+ *
+ * @param string $path
+ * The path of the offending recipe file.
+ * @param string $message
+ * (optional) The exception message.
+ * @param \Symfony\Component\Validator\ConstraintViolationList|null $violations
+ * (optional) A list of validation constraint violations in the recipe file,
+ * if any.
+ * @param int $code
+ * (optional) The exception code.
+ * @param \Throwable|null $previous
+ * (optional) The previous exception, if any.
+ */
+ public function __construct(
+ public readonly string $path,
+ string $message = '',
+ public readonly ?ConstraintViolationList $violations = NULL,
+ int $code = 0,
+ \Throwable $previous = NULL,
+ ) {
+ parent::__construct($message, $code, $previous);
+ }
+
+ /**
+ * Creates an instance of this exception from a set of validation errors.
+ *
+ * @param string $path
+ * The path of the offending recipe file.
+ * @param \Symfony\Component\Validator\ConstraintViolationList $violations
+ * The list of validation constraint violations.
+ *
+ * @return static
+ */
+ public static function fromViolationList(string $path, ConstraintViolationList $violations): static {
+ $lines = ["Validation errors were found in $path:"];
+
+ foreach ($violations as $violation) {
+ $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage());
+ }
+ return new static($path, implode("\n", $lines), $violations);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
new file mode 100644
index 00000000000..cdc21021963
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Assertion\Inspector;
+
+/**
+ * Exception thrown when recipes contain or depend on missing extensions.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeMissingExtensionsException extends \RuntimeException {
+
+ /**
+ * Constructs a RecipeMissingExtensionsException.
+ *
+ * @param array $extensions
+ * The list of missing extensions.
+ * @param string $message
+ * [optional] The Exception message to throw.
+ * @param int $code
+ * [optional] The Exception code.
+ * @param null|\Throwable $previous
+ * [optional] The previous throwable used for the exception chaining.
+ */
+ public function __construct(public readonly array $extensions, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+ assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
+ if (!$message) {
+ $sorted = $this->extensions;
+ sort($sorted);
+ $message = sprintf("The following extensions are missing and are required for this recipe: %s", implode(", ", $sorted));
+ }
+ parent::__construct($message, $code, $previous);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
new file mode 100644
index 00000000000..da5f3d713f1
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Wraps a config storage to allow recipe provided configuration to override it.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class RecipeOverrideConfigStorage implements StorageInterface {
+
+ /**
+ * @param \Drupal\Core\Config\StorageInterface $recipeStorage
+ * The recipe's configuration storage.
+ * @param \Drupal\Core\Config\StorageInterface $wrappedStorage
+ * The storage to override.
+ * @param string $collection
+ * (optional) The collection to store configuration in. Defaults to the
+ * default collection.
+ */
+ public function __construct(
+ protected readonly StorageInterface $recipeStorage,
+ protected readonly StorageInterface $wrappedStorage,
+ protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exists($name): bool {
+ return $this->wrappedStorage->exists($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function read($name): array|bool {
+ if ($this->wrappedStorage->exists($name) && $this->recipeStorage->exists($name)) {
+ return $this->recipeStorage->read($name);
+ }
+ return $this->wrappedStorage->read($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function readMultiple(array $names): array {
+ $data = $this->wrappedStorage->readMultiple($names);
+ foreach ($data as $name => $value) {
+ if ($this->recipeStorage->exists($name)) {
+ $data[$name] = $this->recipeStorage->read($name);
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write($name, array $data): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rename($name, $new_name): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function encode($data): string {
+ return $this->wrappedStorage->encode($data);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decode($raw): array {
+ return $this->wrappedStorage->decode($raw);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function listAll($prefix = ''): array {
+ return $this->wrappedStorage->listAll($prefix);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll($prefix = ''): bool {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createCollection($collection): static {
+ return new static(
+ $this->recipeStorage->createCollection($collection),
+ $this->wrappedStorage->createCollection($collection),
+ $collection
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllCollectionNames(): array {
+ return $this->wrappedStorage->getAllCollectionNames();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCollectionName(): string {
+ return $this->collection;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
new file mode 100644
index 00000000000..39e5d4e2c72
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * Exception thrown when a recipe has configuration that exists already.
+ *
+ * @internal
+ * This API is experimental.
+ */
+class RecipePreExistingConfigException extends \RuntimeException {
+
+ /**
+ * Constructs a RecipePreExistingConfigException.
+ *
+ * @param string $configName
+ * The configuration name that has missing dependencies.
+ * @param string $message
+ * [optional] The Exception message to throw.
+ * @param int $code
+ * [optional] The Exception code.
+ * @param null|\Throwable $previous
+ * [optional] The previous throwable used for the exception chaining.
+ */
+ public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+ parent::__construct($message, $code, $previous);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
new file mode 100644
index 00000000000..626c203d037
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
@@ -0,0 +1,319 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\InstallStorage;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\DefaultContent\Existing;
+use Drupal\Core\DefaultContent\Importer;
+use Drupal\Core\DefaultContent\Finder;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Applies a recipe.
+ *
+ * This class is currently static and use \Drupal::service() in order to put off
+ * having to solve issues caused by container rebuilds during module install and
+ * configuration import.
+ *
+ * @internal
+ * This API is experimental.
+ *
+ * @todo https://www.drupal.org/i/3439717 Determine if there is a better to
+ * inject and re-inject services.
+ */
+final class RecipeRunner {
+
+ /**
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to apply.
+ */
+ public static function processRecipe(Recipe $recipe): void {
+ static::processRecipes($recipe->recipes);
+ static::processInstall($recipe->install, $recipe->config->getConfigStorage());
+ static::processConfiguration($recipe->config);
+ static::processContent($recipe->content);
+ static::triggerEvent($recipe);
+ }
+
+ /**
+ * Triggers the RecipeAppliedEvent.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to apply.
+ * @param array<mixed>|null $context
+ * The batch context if called by a batch.
+ */
+ public static function triggerEvent(Recipe $recipe, ?array &$context = NULL): void {
+ $event = new RecipeAppliedEvent($recipe);
+ \Drupal::service(EventDispatcherInterface::class)->dispatch($event);
+ $context['message'] = t('Applied %recipe recipe.', ['%recipe' => $recipe->name]);
+ $context['results']['recipe'][] = $recipe->name;
+ }
+
+ /**
+ * Applies any recipes listed by the recipe.
+ *
+ * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
+ * The list of recipes to apply.
+ */
+ protected static function processRecipes(RecipeConfigurator $recipes): void {
+ foreach ($recipes->recipes as $recipe) {
+ static::processRecipe($recipe);
+ }
+ }
+
+ /**
+ * Installs the extensions.
+ *
+ * @param \Drupal\Core\Recipe\InstallConfigurator $install
+ * The list of extensions to install.
+ * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage
+ * The recipe's configuration storage. Used to override extension provided
+ * configuration.
+ */
+ protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void {
+ foreach ($install->modules as $name) {
+ static::installModule($name, $recipeConfigStorage);
+ }
+
+ // Themes can depend on modules so have to be installed after modules.
+ foreach ($install->themes as $name) {
+ static::installTheme($name, $recipeConfigStorage);
+ }
+ }
+
+ /**
+ * Creates configuration and applies configuration actions.
+ *
+ * @param \Drupal\Core\Recipe\ConfigConfigurator $config
+ * The config configurator from the recipe.
+ */
+ protected static function processConfiguration(ConfigConfigurator $config): void {
+ $config_installer = new RecipeConfigInstaller(
+ \Drupal::service('config.factory'),
+ \Drupal::service('config.storage'),
+ \Drupal::service('config.typed'),
+ \Drupal::service('config.manager'),
+ \Drupal::service('event_dispatcher'),
+ NULL,
+ \Drupal::service('extension.path.resolver'));
+
+ // Create configuration that is either supplied by the recipe or listed in
+ // the config.import section that does not exist.
+ $config_installer->installRecipeConfig($config);
+
+ if (!empty($config->config['actions'])) {
+ // Process the actions.
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */
+ $config_action_manager = \Drupal::service('plugin.manager.config_action');
+ foreach ($config->config['actions'] as $config_name => $actions) {
+ foreach ($actions as $action_id => $data) {
+ $config_action_manager->applyAction($action_id, $config_name, $data);
+ }
+ }
+ }
+ }
+
+ /**
+ * Creates content contained in a recipe.
+ *
+ * @param \Drupal\Core\DefaultContent\Finder $content
+ * The content finder object for the recipe.
+ */
+ protected static function processContent(Finder $content): void {
+ /** @var \Drupal\Core\DefaultContent\Importer $importer */
+ $importer = \Drupal::service(Importer::class);
+ $importer->setLogger(\Drupal::logger('recipe'));
+ $importer->importContent($content, Existing::Skip);
+ }
+
+ /**
+ * Converts a recipe into a series of batch operations.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to convert to batch operations.
+ *
+ * @return array<int, array{0: callable, 1: array{mixed}}>
+ * The array of batch operations. Each value is an array with two values.
+ * The first value is a callable and the second value are the arguments to
+ * pass to the callable.
+ *
+ * @see \Drupal\Core\Batch\BatchBuilder::addOperation()
+ */
+ public static function toBatchOperations(Recipe $recipe): array {
+ $modules = [];
+ $themes = [];
+ $recipes = [];
+ return static::toBatchOperationsRecipe($recipe, $recipes, $modules, $themes);
+ }
+
+ /**
+ * Helper method to convert a recipe to batch operations.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to convert to batch operations.
+ * @param string[] $recipes
+ * The paths of the recipes that have already been converted to batch operations.
+ * @param string[] $modules
+ * The modules that will already be installed due to previous recipes in the
+ * batch.
+ * @param string[] $themes
+ * The themes that will already be installed due to previous recipes in the
+ * batch.
+ *
+ * @return array<int, array{0: callable, 1: array{mixed}}>
+ * The array of batch operations. Each value is an array with two values.
+ * The first value is a callable and the second value are the arguments to
+ * pass to the callable.
+ */
+ protected static function toBatchOperationsRecipe(Recipe $recipe, array $recipes, array &$modules, array &$themes): array {
+ if (in_array($recipe->path, $recipes, TRUE)) {
+ return [];
+ }
+ $steps = [];
+ $recipes[] = $recipe->path;
+
+ foreach ($recipe->recipes->recipes as $sub_recipe) {
+ $steps = array_merge($steps, static::toBatchOperationsRecipe($sub_recipe, $recipes, $modules, $themes));
+ }
+ $steps = array_merge($steps, static::toBatchOperationsInstall($recipe, $modules, $themes));
+ if ($recipe->config->hasTasks()) {
+ $steps[] = [[RecipeRunner::class, 'installConfig'], [$recipe]];
+ }
+ if (!empty($recipe->content->data)) {
+ $steps[] = [[RecipeRunner::class, 'installContent'], [$recipe]];
+ }
+ $steps[] = [[RecipeRunner::class, 'triggerEvent'], [$recipe]];
+
+ return $steps;
+ }
+
+ /**
+ * Converts a recipe's install tasks to batch operations.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to convert install tasks to batch operations.
+ * @param string[] $modules
+ * The modules that will already be installed due to previous recipes in the
+ * batch.
+ * @param string[] $themes
+ * The themes that will already be installed due to previous recipes in the
+ * batch.
+ *
+ * @return array<int, array{0: callable, 1: array{mixed}}>
+ * The array of batch operations. Each value is an array with two values.
+ * The first value is a callable and the second value are the arguments to
+ * pass to the callable.
+ */
+ protected static function toBatchOperationsInstall(Recipe $recipe, array &$modules, array &$themes): array {
+ foreach ($recipe->install->modules as $name) {
+ if (in_array($name, $modules, TRUE)) {
+ continue;
+ }
+ $modules[] = $name;
+ $steps[] = [[RecipeRunner::class, 'installModule'], [$name, $recipe]];
+ }
+ foreach ($recipe->install->themes as $name) {
+ if (in_array($name, $themes, TRUE)) {
+ continue;
+ }
+ $themes[] = $name;
+ $steps[] = [[RecipeRunner::class, 'installTheme'], [$name, $recipe]];
+ }
+ return $steps ?? [];
+ }
+
+ /**
+ * Installs a module for a recipe.
+ *
+ * @param string $module
+ * The name of the module to install.
+ * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
+ * The recipe or recipe's config storage.
+ * @param array<mixed>|null $context
+ * The batch context if called by a batch.
+ */
+ public static function installModule(string $module, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
+ if ($recipeConfigStorage instanceof Recipe) {
+ $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
+ }
+ // Disable configuration entity install but use the config directory from
+ // the module.
+ \Drupal::service('config.installer')->setSyncing(TRUE);
+ $default_install_path = \Drupal::service('extension.list.module')->get($module)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
+ // Allow the recipe to override simple configuration from the module.
+ $storage = new RecipeOverrideConfigStorage(
+ $recipeConfigStorage,
+ new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
+ );
+ \Drupal::service('config.installer')->setSourceStorage($storage);
+
+ \Drupal::service('module_installer')->install([$module]);
+ \Drupal::service('config.installer')->setSyncing(FALSE);
+ $context['message'] = t('Installed %module module.', ['%module' => \Drupal::service('extension.list.module')->getName($module)]);
+ $context['results']['module'][] = $module;
+ }
+
+ /**
+ * Installs a theme for a recipe.
+ *
+ * @param string $theme
+ * The name of the theme to install.
+ * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
+ * The recipe or recipe's config storage.
+ * @param array<mixed>|null $context
+ * The batch context if called by a batch.
+ */
+ public static function installTheme(string $theme, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
+ if ($recipeConfigStorage instanceof Recipe) {
+ $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
+ }
+ // Disable configuration entity install.
+ \Drupal::service('config.installer')->setSyncing(TRUE);
+ $default_install_path = \Drupal::service('extension.list.theme')->get($theme)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
+ // Allow the recipe to override simple configuration from the theme.
+ $storage = new RecipeOverrideConfigStorage(
+ $recipeConfigStorage,
+ new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
+ );
+ \Drupal::service('config.installer')->setSourceStorage($storage);
+
+ \Drupal::service('theme_installer')->install([$theme]);
+ \Drupal::service('config.installer')->setSyncing(FALSE);
+ $context['message'] = t('Installed %theme theme.', ['%theme' => \Drupal::service('extension.list.theme')->getName($theme)]);
+ $context['results']['theme'][] = $theme;
+ }
+
+ /**
+ * Installs a config for a recipe.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to install config for.
+ * @param array<mixed>|null $context
+ * The batch context if called by a batch.
+ */
+ public static function installConfig(Recipe $recipe, ?array &$context = NULL): void {
+ static::processConfiguration($recipe->config);
+ $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]);
+ $context['results']['config'][] = $recipe->name;
+ }
+
+ /**
+ * Installs a content for a recipe.
+ *
+ * @param \Drupal\Core\Recipe\Recipe $recipe
+ * The recipe to install content for.
+ * @param array<mixed>|null $context
+ * The batch context if called by a batch.
+ */
+ public static function installContent(Recipe $recipe, ?array &$context = NULL): void {
+ static::processContent($recipe->content);
+ $context['message'] = t('Created content for %recipe recipe.', ['%recipe' => $recipe->name]);
+ $context['results']['content'][] = $recipe->name;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
new file mode 100644
index 00000000000..b0f63c00282
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * Exception thrown when recipe is can not be found.
+ *
+ * @internal
+ * This API is experimental.
+ */
+final class UnknownRecipeException extends \RuntimeException {
+
+ /**
+ * @param string $recipe
+ * The recipe's name.
+ * @param string $searchPath
+ * The path searched for the recipe.
+ * @param string $message
+ * (optional) The exception message.
+ * @param int $code
+ * (optional) The exception code.
+ * @param \Throwable|null $previous
+ * (optional) The previous exception.
+ */
+ public function __construct(public readonly string $recipe, public readonly string $searchPath, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+ parent::__construct($message, $code, $previous);
+ }
+
+}
diff --git a/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php
new file mode 100644
index 00000000000..f239e226bc2
--- /dev/null
+++ b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\ckeditor5\Plugin\ConfigAction;
+
+use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\editor\EditorInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+#[ConfigAction(
+ id: 'editor:addItemToToolbar',
+ admin_label: new TranslatableMarkup('Add an item to a CKEditor 5 toolbar'),
+ entity_types: ['editor'],
+)]
+final class AddItemToToolbar implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ public function __construct(
+ private readonly ConfigManagerInterface $configManager,
+ private readonly CKEditor5PluginManagerInterface $pluginManager,
+ private readonly string $pluginId,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $container->get(ConfigManagerInterface::class),
+ $container->get(CKEditor5PluginManagerInterface::class),
+ $plugin_id,
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ $editor = $this->configManager->loadConfigEntityByName($configName);
+ assert($editor instanceof EditorInterface);
+
+ if ($editor->getEditor() !== 'ckeditor5') {
+ throw new ConfigActionException(sprintf('The %s config action only works with editors that use CKEditor 5.', $this->pluginId));
+ }
+
+ $editor_settings = $editor->getSettings();
+ if (is_string($value)) {
+ $editor_settings['toolbar']['items'][] = $item_name = $value;
+ }
+ else {
+ assert(is_array($value));
+
+ $item_name = $value['item_name'];
+ assert(is_string($item_name));
+
+ $replace = $value['replace'] ?? FALSE;
+ assert(is_bool($replace));
+
+ $position = $value['position'] ?? NULL;
+ if (is_int($position)) {
+ // If we want to replace the item at this position, then `replace`
+ // should be true. This would be useful if, for example, we wanted to
+ // replace the Image button with the Media Library.
+ array_splice($editor_settings['toolbar']['items'], $position, $replace ? 1 : 0, $item_name);
+ }
+ else {
+ $editor_settings['toolbar']['items'][] = $item_name;
+ }
+ }
+ // If we're just adding a vertical separator, there's nothing else we need
+ // to do at this point.
+ if ($item_name === '|') {
+ return;
+ }
+
+ // If this item is associated with a plugin, ensure that it's configured
+ // at the editor level, if necessary.
+ /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $definition */
+ foreach ($this->pluginManager->getDefinitions() as $id => $definition) {
+ if (array_key_exists($item_name, $definition->getToolbarItems())) {
+ // If plugin settings already exist, don't change them.
+ if (array_key_exists($id, $editor_settings['plugins'])) {
+ break;
+ }
+ elseif ($definition->isConfigurable()) {
+ /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface $plugin */
+ $plugin = $this->pluginManager->getPlugin($id, NULL);
+ $editor_settings['plugins'][$id] = $plugin->defaultConfiguration();
+ }
+ // No need to examine any other plugins.
+ break;
+ }
+ }
+
+ $editor->setSettings($editor_settings)->save();
+ }
+
+}
diff --git a/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php
new file mode 100644
index 00000000000..c4c8b8e953a
--- /dev/null
+++ b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction;
+
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\editor\Entity\Editor;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar
+ * @group ckeditor5
+ * @group Recipe
+ */
+class AddItemToToolbarConfigActionTest extends KernelTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'ckeditor5',
+ 'editor',
+ 'filter',
+ 'filter_test',
+ 'user',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $configSchemaCheckerExclusions = [
+ // This test must be allowed to save invalid config, we can confirm that
+ // any invalid stuff is validated by the config actions system.
+ 'editor.editor.filter_test',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installConfig('filter_test');
+
+ $editor = Editor::create([
+ 'editor' => 'ckeditor5',
+ 'format' => 'filter_test',
+ 'image_upload' => ['status' => FALSE],
+ ]);
+ $editor->save();
+
+ /** @var array{toolbar: array{items: array<int, string>}} $settings */
+ $settings = Editor::load('filter_test')?->getSettings();
+ $this->assertSame(['heading', 'bold', 'italic'], $settings['toolbar']['items']);
+ }
+
+ /**
+ * @param string|array<string, mixed> $action
+ * The value to pass to the config action.
+ * @param string[] $expected_toolbar_items
+ * The items which should be in the editor toolbar, in the expected order.
+ *
+ * @testWith ["sourceEditing", ["heading", "bold", "italic", "sourceEditing"]]
+ * [{"item_name": "sourceEditing"}, ["heading", "bold", "italic", "sourceEditing"]]
+ * [{"item_name": "sourceEditing", "position": 1}, ["heading", "sourceEditing", "bold", "italic"]]
+ * [{"item_name": "sourceEditing", "position": 1, "replace": true}, ["heading", "sourceEditing", "italic"]]
+ */
+ public function testAddItemToToolbar(string|array $action, array $expected_toolbar_items): void {
+ $recipe = $this->createRecipe([
+ 'name' => 'CKEditor 5 toolbar item test',
+ 'config' => [
+ 'actions' => [
+ 'editor.editor.filter_test' => [
+ 'addItemToToolbar' => $action,
+ ],
+ ],
+ ],
+ ]);
+ RecipeRunner::processRecipe($recipe);
+
+ /** @var array{toolbar: array{items: string[]}, plugins: array<string, array<mixed>>} $settings */
+ $settings = Editor::load('filter_test')?->getSettings();
+ $this->assertSame($expected_toolbar_items, $settings['toolbar']['items']);
+ // The plugin's default settings should have been added.
+ $this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
+ }
+
+ public function testAddNonExistentItem(): void {
+ $recipe = $this->createRecipe([
+ 'name' => 'Add an invalid toolbar item',
+ 'config' => [
+ 'actions' => [
+ 'editor.editor.filter_test' => [
+ 'addItemToToolbar' => 'bogus_item',
+ ],
+ ],
+ ],
+ ]);
+
+ $this->expectException(InvalidConfigException::class);
+ $this->expectExceptionMessage("There were validation errors in editor.editor.filter_test:\n- settings.toolbar.items.3: The provided toolbar item <em class=\"placeholder\">bogus_item</em> is not valid.");
+ RecipeRunner::processRecipe($recipe);
+ }
+
+ public function testActionRequiresCKEditor5(): void {
+ $this->enableModules(['editor_test']);
+ Editor::load('filter_test')?->setEditor('unicorn')->setSettings([])->save();
+
+ $recipe = <<<YAML
+name: Not a CKEditor
+config:
+ actions:
+ editor.editor.filter_test:
+ addItemToToolbar: strikethrough
+YAML;
+
+ $this->expectException(ConfigActionException::class);
+ $this->expectExceptionMessage('The editor:addItemToToolbar config action only works with editors that use CKEditor 5.');
+ RecipeRunner::processRecipe($this->createRecipe($recipe));
+ }
+
+}
diff --git a/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
new file mode 100644
index 00000000000..2f1dd51d4c6
--- /dev/null
+++ b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
@@ -0,0 +1,4 @@
+name: 'Config action duplicate test'
+type: module
+package: Testing
+version: VERSION
diff --git a/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
new file mode 100644
index 00000000000..560b2c7241b
--- /dev/null
+++ b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\config_action_duplicate_test\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+#[ConfigAction(
+ id: 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty',
+ admin_label: new TranslatableMarkup('A duplicate config action'),
+ entity_types: ['config_test'],
+)]
+final class DuplicateConfigAction implements ConfigActionPluginInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ // This method should never be called.
+ throw new \BadMethodCallException();
+ }
+
+}
diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
index b0118f68211..99b7cfd8f06 100644
--- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
+++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
@@ -24,6 +24,9 @@ config_test_dynamic:
protected_property:
type: string
label: 'Protected property'
+ array_property:
+ type: ignore
+ label: 'Array property'
config_test.dynamic.*:
type: config_test_dynamic
diff --git a/core/modules/config/tests/config_test/config_test.module b/core/modules/config/tests/config_test/config_test.module
index b9fac8f793b..98e50125141 100644
--- a/core/modules/config/tests/config_test/config_test.module
+++ b/core/modules/config/tests/config_test/config_test.module
@@ -42,6 +42,10 @@ function config_test_entity_type_alter(array &$entity_types) {
if (\Drupal::service('state')->get('config_test.lookup_keys', FALSE)) {
$entity_types['config_test']->set('lookup_keys', ['uuid', 'style']);
}
+
+ if (\Drupal::service('state')->get('config_test.class_override', FALSE)) {
+ $entity_types['config_test']->setClass(\Drupal::service('state')->get('config_test.class_override'));
+ }
}
/**
diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
new file mode 100644
index 00000000000..b51e41af5d9
--- /dev/null
+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Drupal\config_test\ConfigActionErrorEntity;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+
+/**
+ * Test entity class.
+ */
+class DuplicatePluralizedMethodName extends ConfigTest {
+
+ #[ActionMethod(pluralize: 'testMethod')]
+ public function testMethod() {
+ }
+
+}
diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
new file mode 100644
index 00000000000..98c5d07559a
--- /dev/null
+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\config_test\ConfigActionErrorEntity;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+
+/**
+ * Test entity class.
+ */
+class DuplicatePluralizedOtherMethodName extends ConfigTest {
+
+ #[ActionMethod(pluralize: 'testMethod2')]
+ public function testMethod() {
+ }
+
+ #[ActionMethod()]
+ public function testMethod2() {
+ }
+
+}
diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
index 6b9e8007dbf..b461828a635 100644
--- a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
+++ b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
@@ -46,4 +46,13 @@ class ConfigQueryTest extends ConfigTest {
*/
public $array = [];
+ /**
+ * {@inheritdoc}
+ */
+ public function concatProtectedProperty(string $value1, string $value2): static {
+ // This method intentionally does not have the config action attribute to
+ // ensure it is still discovered.
+ return parent::concatProtectedProperty($value1, $value2);
+ }
+
}
diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
index 30a7a3526d0..e2597f0277d 100644
--- a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
+++ b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
@@ -2,10 +2,12 @@
namespace Drupal\config_test\Entity;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\config_test\ConfigTestInterface;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines the ConfigTest configuration entity.
@@ -36,6 +38,7 @@ use Drupal\Core\Entity\EntityStorageInterface;
* "size",
* "size_value",
* "protected_property",
+ * "array_property",
* },
* links = {
* "edit-form" = "/admin/structure/config_test/manage/{config_test}",
@@ -84,6 +87,13 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface {
protected $protected_property;
/**
+ * An array property of the configuration entity.
+ *
+ * @var array
+ */
+ protected array $array_property = [];
+
+ /**
* {@inheritdoc}
*/
public static function sort(ConfigEntityInterface $a, ConfigEntityInterface $b) {
@@ -188,4 +198,142 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface {
return $this->id != 'isinstallable' || \Drupal::state()->get('config_test.isinstallable');
}
+ /**
+ * Sets the protected property value.
+ *
+ * @param $value
+ * The value to set.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod(pluralize: FALSE)]
+ public function setProtectedProperty(string $value): static {
+ $this->protected_property = $value;
+ return $this;
+ }
+
+ /**
+ * Gets the protected property value.
+ *
+ * @return string
+ * The protected property value.
+ */
+ public function getProtectedProperty(): string {
+ return $this->protected_property;
+ }
+
+ /**
+ * Concatenates the two params and sets the protected property value.
+ *
+ * @param $value1
+ * The first value to concatenate.
+ * @param $value2
+ * The second value to concatenate.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod()]
+ public function concatProtectedProperty(string $value1, string $value2): static {
+ $this->protected_property = $value1 . $value2;
+ return $this;
+ }
+
+ /**
+ * Concatenates up to two params and sets the protected property value.
+ *
+ * @param $value1
+ * The first value to concatenate.
+ * @param $value2
+ * (optional) The second value to concatenate. Defaults to ''.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod(pluralize: FALSE)]
+ public function concatProtectedPropertyOptional(string $value1, string $value2 = ''): static {
+ $this->protected_property = $value1 . $value2;
+ return $this;
+ }
+
+ /**
+ * Appends to protected property.
+ *
+ * @param $value
+ * The value to append.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod()]
+ public function append(string $value): static {
+ $this->protected_property .= $value;
+ return $this;
+ }
+
+ /**
+ * Sets the protected property to a default value.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod(pluralize: FALSE, adminLabel: new TranslatableMarkup('Set default name'))]
+ public function defaultProtectedProperty(): static {
+ $this->protected_property = 'Set by method';
+ return $this;
+ }
+
+ /**
+ * Adds a value to the array property.
+ *
+ * @param mixed $value
+ * The value to add.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod(pluralize: 'addToArrayMultipleTimes')]
+ public function addToArray(mixed $value): static {
+ $this->array_property[] = $value;
+ return $this;
+ }
+
+ /**
+ * Gets the array property value.
+ *
+ * @return array
+ * The array property value.
+ */
+ public function getArrayProperty(): array {
+ return $this->array_property;
+ }
+
+ /**
+ * Sets the array property.
+ *
+ * @param $value
+ * The value to set.
+ *
+ * @return $this
+ * The config entity.
+ */
+ #[ActionMethod(pluralize: FALSE)]
+ public function setArray(array $value): static {
+ $this->array_property = $value;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function toArray() {
+ $properties = parent::toArray();
+ // Only export the 'array_property' is there is data.
+ if (empty($properties['array_property'])) {
+ unset($properties['array_property']);
+ }
+ return $properties;
+ }
+
}
diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php
new file mode 100644
index 00000000000..a2df177036b
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\ConfigAction;
+
+use Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workflows\WorkflowInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+#[ConfigAction(
+ id: 'add_moderation',
+ entity_types: ['workflow'],
+ deriver: AddModerationDeriver::class,
+)]
+final class AddModeration implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ public function __construct(
+ private readonly ConfigManagerInterface $configManager,
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ private readonly string $pluginId,
+ private readonly string $targetEntityType,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ assert(is_array($plugin_definition));
+ $target_entity_type = $plugin_definition['target_entity_type'];
+
+ return new static(
+ $container->get(ConfigManagerInterface::class),
+ $container->get(EntityTypeManagerInterface::class),
+ $plugin_id,
+ $target_entity_type,
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ $workflow = $this->configManager->loadConfigEntityByName($configName);
+ assert($workflow instanceof WorkflowInterface);
+
+ $plugin = $workflow->getTypePlugin();
+ if (!$plugin instanceof ContentModerationInterface) {
+ throw new ConfigActionException("The $this->pluginId config action only works with Content Moderation workflows.");
+ }
+
+ assert($value === '*' || is_array($value));
+ if ($value === '*') {
+ /** @var \Drupal\Core\Entity\EntityTypeInterface $definition */
+ $definition = $this->entityTypeManager->getDefinition($this->targetEntityType);
+ /** @var string $bundle_entity_type */
+ $bundle_entity_type = $definition->getBundleEntityType();
+
+ $value = $this->entityTypeManager->getStorage($bundle_entity_type)
+ ->getQuery()
+ ->accessCheck(FALSE)
+ ->execute();
+ }
+ foreach ($value as $bundle) {
+ $plugin->addEntityTypeAndBundle($this->targetEntityType, $bundle);
+ }
+ $workflow->save();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php
new file mode 100644
index 00000000000..fec8c773e66
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\content_moderation\Plugin\ConfigAction;
+
+// cspell:ignore inflector
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\Container;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\String\Inflector\EnglishInflector;
+
+final class AddModerationDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+ use StringTranslationTrait;
+
+ public function __construct(
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, $base_plugin_id): static {
+ return new static(
+ $container->get(EntityTypeManagerInterface::class),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ $inflector = new EnglishInflector();
+
+ foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
+ if ($bundle_entity_type = $entity_type->getBundleEntityType()) {
+ /** @var \Drupal\Core\Entity\EntityTypeInterface $bundle_entity_type */
+ $bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_entity_type);
+ // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
+ // like `TaxonomyVocabulary`.
+ $suffix = Container::camelize($bundle_entity_type->id());
+ [$suffix] = $inflector->pluralize($suffix);
+ $this->derivatives["add{$suffix}"] = [
+ 'target_entity_type' => $id,
+ 'admin_label' => $this->t('Add moderation to all @bundles', [
+ '@bundles' => $bundle_entity_type->getPluralLabel() ?: $bundle_entity_type->id(),
+ ]),
+ ] + $base_plugin_definition;
+ }
+ }
+ return parent::getDerivativeDefinitions($base_plugin_definition);
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php
new file mode 100644
index 00000000000..4d5afd395e2
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\content_moderation\Kernel\ConfigAction;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Drupal\workflows\Entity\Workflow;
+
+/**
+ * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModeration
+ * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModerationDeriver
+ * @group content_moderation
+ * @group Recipe
+ */
+class AddModerationConfigActionTest extends KernelTestBase {
+
+ use ContentTypeCreationTrait;
+ use RecipeTestTrait {
+ createRecipe as traitCreateRecipe;
+ }
+ use TaxonomyTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'field',
+ 'node',
+ 'system',
+ 'taxonomy',
+ 'text',
+ 'user',
+ ];
+
+ public function testAddEntityTypeAndBundle(): void {
+ $this->installConfig('node');
+
+ $this->createContentType(['type' => 'a']);
+ $this->createContentType(['type' => 'b']);
+ $this->createContentType(['type' => 'c']);
+ $this->createVocabulary(['vid' => 'tags']);
+
+ $recipe = $this->createRecipe('workflows.workflow.editorial');
+ RecipeRunner::processRecipe($recipe);
+
+ /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $plugin */
+ $plugin = Workflow::load('editorial')?->getTypePlugin();
+ $this->assertSame(['a', 'b'], $plugin->getBundlesForEntityType('node'));
+ $this->assertSame(['tags'], $plugin->getBundlesForEntityType('taxonomy_term'));
+ }
+
+ public function testWorkflowMustBeContentModeration(): void {
+ $this->enableModules(['workflows', 'workflow_type_test']);
+
+ $workflow = Workflow::create([
+ 'id' => 'test',
+ 'label' => 'Test workflow',
+ 'type' => 'workflow_type_test',
+ ]);
+ $workflow->save();
+
+ $recipe = $this->createRecipe($workflow->getConfigDependencyName());
+ $this->expectException(ConfigActionException::class);
+ $this->expectExceptionMessage("The add_moderation:addNodeTypes config action only works with Content Moderation workflows.");
+ RecipeRunner::processRecipe($recipe);
+ }
+
+ public function testActionOnlyTargetsWorkflows(): void {
+ $recipe = $this->createRecipe('user.role.anonymous');
+ $this->expectException(PluginNotFoundException::class);
+ $this->expectExceptionMessage('The "addNodeTypes" plugin does not exist.');
+ RecipeRunner::processRecipe($recipe);
+ }
+
+ public function testDeriverAdminLabel(): void {
+ $this->enableModules(['workflows', 'content_moderation']);
+
+ /** @var array<string, array{admin_label: \Stringable}> $definitions */
+ $definitions = $this->container->get('plugin.manager.config_action')
+ ->getDefinitions();
+
+ $this->assertSame('Add moderation to all content types', (string) $definitions['add_moderation:addNodeTypes']['admin_label']);
+ $this->assertSame('Add moderation to all vocabularies', (string) $definitions['add_moderation:addTaxonomyVocabularies']['admin_label']);
+ }
+
+ private function createRecipe(string $config_name): Recipe {
+ $recipe = <<<YAML
+name: 'Add entity types and bundles to workflow'
+recipes:
+ - core/recipes/editorial_workflow
+config:
+ actions:
+ $config_name:
+ addNodeTypes:
+ - a
+ - b
+ addTaxonomyVocabularies: '*'
+YAML;
+ return $this->traitCreateRecipe($recipe);
+ }
+
+}
diff --git a/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php
new file mode 100644
index 00000000000..e1642a80bba
--- /dev/null
+++ b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\field\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\field\FieldStorageConfigInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Adds a field to all bundles of its target entity type.
+ *
+ * @internal
+ * This API is experimental.
+ */
+#[ConfigAction(
+ id: 'field_storage_config:addToAllBundles',
+ admin_label: new TranslatableMarkup('Add a field to all bundles'),
+ entity_types: ['field_storage_config'],
+)]
+final class AddToAllBundles implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+ public function __construct(
+ private readonly EntityTypeManagerInterface $entityTypeManager,
+ private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo,
+ private readonly ConfigManagerInterface $configManager,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ return new static(
+ $container->get(EntityTypeManagerInterface::class),
+ $container->get(EntityTypeBundleInfoInterface::class),
+ $container->get(ConfigManagerInterface::class),
+ );
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(string $configName, mixed $value): void {
+ assert(is_array($value));
+
+ $field_storage = $this->configManager->loadConfigEntityByName($configName);
+ assert($field_storage instanceof FieldStorageConfigInterface);
+
+ $storage = $this->entityTypeManager->getStorage('field_config');
+
+ $entity_type_id = $field_storage->getTargetEntityTypeId();
+ $field_name = $field_storage->getName();
+
+ $existing_fields = $storage->getQuery()
+ ->condition('entity_type', $entity_type_id)
+ ->condition('field_name', $field_name)
+ ->execute();
+
+ // Get all bundles of the target entity type.
+ $bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
+ foreach ($bundles as $bundle) {
+ $id = "$entity_type_id.$bundle.$field_name";
+ if (in_array($id, $existing_fields, TRUE)) {
+ if (empty($value['fail_if_exists'])) {
+ continue;
+ }
+ throw new ConfigActionException(sprintf('Field %s already exists.', $id));
+ }
+ $storage->create([
+ 'label' => $value['label'],
+ 'bundle' => $bundle,
+ 'description' => $value['description'],
+ 'field_storage' => $field_storage,
+ ])->save();
+ }
+ }
+
+}
diff --git a/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php
new file mode 100644
index 00000000000..4f6ec730877
--- /dev/null
+++ b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\field\Kernel;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @covers \Drupal\field\Plugin\ConfigAction\AddToAllBundles
+ *
+ * @group Recipe
+ * @group field
+ */
+class AddToAllBundlesConfigActionTest extends KernelTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['field', 'node', 'system', 'text', 'user'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ NodeType::create([
+ 'type' => 'one',
+ 'name' => 'One',
+ ])->save();
+ NodeType::create([
+ 'type' => 'two',
+ 'name' => 'Two',
+ ])->save();
+ }
+
+ /**
+ * Tests instantiating a field on all bundles of an entity type.
+ */
+ public function testInstantiateNewFieldOnAllBundles(): void {
+ // Ensure the body field doesn't actually exist yet.
+ $storage_definitions = $this->container->get(EntityFieldManagerInterface::class)
+ ->getFieldStorageDefinitions('node');
+ $this->assertArrayNotHasKey('body', $storage_definitions);
+
+ $this->applyAction('field.storage.node.body');
+
+ // Fields and expected data exist.
+ /** @var \Drupal\field\FieldConfigInterface[] $body_fields */
+ $body_fields = $this->container->get(EntityTypeManagerInterface::class)
+ ->getStorage('field_config')
+ ->loadByProperties([
+ 'entity_type' => 'node',
+ 'field_name' => 'body',
+ ]);
+ ksort($body_fields);
+ $this->assertSame(['node.one.body', 'node.two.body'], array_keys($body_fields));
+ foreach ($body_fields as $field) {
+ $this->assertSame('Body field label', $field->label());
+ $this->assertSame('Set by config actions.', $field->getDescription());
+ }
+
+ // Expect an error when the 'addToAllBundles' action is invoked on anything
+ // other than a field storage config entity.
+ $this->expectException(PluginNotFoundException::class);
+ $this->expectExceptionMessage('The "addToAllBundles" plugin does not exist.');
+ $this->applyAction('user.role.anonymous');
+ }
+
+ /**
+ * Tests that the action can be set to fail if the field already exists.
+ */
+ public function testFailIfExists(): void {
+ $this->installConfig('node');
+ node_add_body_field(NodeType::load('one'));
+
+ $this->expectException(ConfigActionException::class);
+ $this->expectExceptionMessage('Field node.one.body already exists.');
+ $this->applyAction('field.storage.node.body', TRUE);
+ }
+
+ /**
+ * Tests that the action will ignore existing fields by default.
+ */
+ public function testIgnoreExistingFields(): void {
+ $this->installConfig('node');
+
+ node_add_body_field(NodeType::load('one'))
+ ->setLabel('Original label')
+ ->setDescription('Original description')
+ ->save();
+
+ $this->applyAction('field.storage.node.body');
+
+ // The existing field should not be changed.
+ $field = FieldConfig::loadByName('node', 'one', 'body');
+ $this->assertInstanceOf(FieldConfig::class, $field);
+ $this->assertSame('Original label', $field->label());
+ $this->assertSame('Original description', $field->getDescription());
+
+ // But the new field should be created as expected.
+ $field = FieldConfig::loadByName('node', 'two', 'body');
+ $this->assertInstanceOf(FieldConfig::class, $field);
+ $this->assertSame('Body field label', $field->label());
+ $this->assertSame('Set by config actions.', $field->getDescription());
+ }
+
+ /**
+ * Applies a recipe with the addToAllBundles action.
+ *
+ * @param string $config_name
+ * The name of the config object which should run the addToAllBundles
+ * action.
+ * @param bool $fail_if_exists
+ * (optional) Whether the action should fail if the field already exists on
+ * any bundle. Defaults to FALSE.
+ */
+ private function applyAction(string $config_name, bool $fail_if_exists = FALSE): void {
+ $fail_if_exists = var_export($fail_if_exists, TRUE);
+ $contents = <<<YAML
+name: Instantiate field on all bundles
+config:
+ import:
+ node:
+ - field.storage.node.body
+ actions:
+ $config_name:
+ addToAllBundles:
+ label: Body field label
+ description: Set by config actions.
+ fail_if_exists: $fail_if_exists
+YAML;
+ $recipe = $this->createRecipe($contents);
+ RecipeRunner::processRecipe($recipe);
+ }
+
+}
diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php
index 1742bcf1b43..699b6a99d4a 100644
--- a/core/modules/filter/src/Entity/FilterFormat.php
+++ b/core/modules/filter/src/Entity/FilterFormat.php
@@ -3,9 +3,11 @@
namespace Drupal\filter\Entity;
use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\FilterPluginCollection;
use Drupal\filter\Plugin\FilterInterface;
@@ -160,6 +162,7 @@ class FilterFormat extends ConfigEntityBase implements FilterFormatInterface, En
/**
* {@inheritdoc}
*/
+ #[ActionMethod(adminLabel: new TranslatableMarkup('Sets configuration for a filter plugin'))]
public function setFilterConfig($instance_id, array $configuration) {
$this->filters[$instance_id] = $configuration;
if (isset($this->filterCollection)) {
diff --git a/core/modules/user/src/Entity/Role.php b/core/modules/user/src/Entity/Role.php
index 18194f3390e..68ddd501f51 100644
--- a/core/modules/user/src/Entity/Role.php
+++ b/core/modules/user/src/Entity/Role.php
@@ -2,8 +2,10 @@
namespace Drupal\user\Entity;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\RoleInterface;
/**
@@ -126,6 +128,7 @@ class Role extends ConfigEntityBase implements RoleInterface {
/**
* {@inheritdoc}
*/
+ #[ActionMethod(adminLabel: new TranslatableMarkup('Add permission to role'))]
public function grantPermission($permission) {
if ($this->isAdmin()) {
return $this;
diff --git a/core/profiles/standard/tests/src/Functional/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php
index cbe8f38fc60..4bb993ffc94 100644
--- a/core/profiles/standard/tests/src/Functional/StandardTest.php
+++ b/core/profiles/standard/tests/src/Functional/StandardTest.php
@@ -4,22 +4,8 @@ declare(strict_types=1);
namespace Drupal\Tests\standard\Functional;
-use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
-use Drupal\Component\Utility\Html;
-use Drupal\editor\Entity\Editor;
-use Drupal\image\Entity\ImageStyle;
-use Drupal\media\Entity\MediaType;
-use Drupal\media\Plugin\media\Source\Image;
-use Drupal\Tests\SchemaCheckTestTrait;
-use Drupal\contact\Entity\ContactForm;
-use Drupal\Core\Url;
-use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber;
-use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
-use Drupal\Tests\RequirementsPageTrait;
-use Drupal\user\Entity\Role;
-use Drupal\user\Entity\User;
-use Symfony\Component\Validator\ConstraintViolation;
+use Drupal\Tests\standard\Traits\StandardTestTrait;
/**
* Tests Standard installation profile expectations.
@@ -27,289 +13,8 @@ use Symfony\Component\Validator\ConstraintViolation;
* @group standard
*/
class StandardTest extends BrowserTestBase {
-
- use SchemaCheckTestTrait;
- use RequirementsPageTrait;
+ use StandardTestTrait;
protected $profile = 'standard';
- /**
- * The admin user.
- *
- * @var \Drupal\user\UserInterface
- */
- protected $adminUser;
-
- /**
- * Tests Standard installation profile.
- */
- public function testStandard() {
- $this->drupalGet('');
- $this->assertSession()->pageTextContains('Powered by Drupal');
-
- // Test anonymous user can access 'Main navigation' block.
- $this->adminUser = $this->drupalCreateUser([
- 'administer blocks',
- 'administer block content',
- 'post comments',
- 'skip comment approval',
- 'create article content',
- 'create page content',
- ]);
- $this->drupalLogin($this->adminUser);
- // Configure the block.
- $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero');
- $this->submitForm([
- 'region' => 'sidebar',
- 'id' => 'main_navigation',
- ], 'Save block');
- // Verify admin user can see the block.
- $this->drupalGet('');
- $this->assertSession()->pageTextContains('Main navigation');
-
- // Verify we have role = complementary on help_block blocks.
- $this->drupalGet('admin/structure/block');
- $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary');
-
- // Verify anonymous user can see the block.
- $this->drupalLogout();
- $this->assertSession()->pageTextContains('Main navigation');
-
- // Ensure comments don't show in the front page RSS feed.
- // Create an article.
- $this->drupalCreateNode([
- 'type' => 'article',
- 'title' => 'Foobar',
- 'promote' => 1,
- 'status' => 1,
- 'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']],
- ]);
-
- // Add a comment.
- $this->drupalLogin($this->adminUser);
- $this->drupalGet('node/1');
- // Verify that a line break is present.
- $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me');
- $this->submitForm([
- 'subject[0][value]' => 'Bar foo',
- 'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me',
- ], 'Save');
- // Fetch the feed.
- $this->drupalGet('rss.xml');
- $this->assertSession()->responseContains('Foobar');
- $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me');
-
- // Ensure block body exists.
- $this->drupalGet('block/add');
- $this->assertSession()->fieldExists('body[0][value]');
-
- // Now we have all configuration imported, test all of them for schema
- // conformance. Ensures all imported default configuration is valid when
- // standard profile modules are enabled.
- $names = $this->container->get('config.storage')->listAll();
- /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */
- $typed_config = $this->container->get('config.typed');
- foreach ($names as $name) {
- $config = $this->config($name);
- $this->assertConfigSchema($typed_config, $name, $config->get());
- }
-
- // Validate all configuration.
- // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373
- foreach (Editor::loadMultiple() as $editor) {
- // Currently only text editors using CKEditor 5 can be validated.
- if ($editor->getEditor() !== 'ckeditor5') {
- continue;
- }
-
- $this->assertSame([], array_map(
- function (ConstraintViolation $v) {
- return (string) $v->getMessage();
- },
- iterator_to_array(CKEditor5::validatePair(
- $editor,
- $editor->getFilterFormat()
- ))
- ));
- }
-
- // Ensure that configuration from the Standard profile is not reused when
- // enabling a module again since it contains configuration that can not be
- // installed. For example, editor.editor.basic_html is editor configuration
- // that depends on the CKEditor 5 module. The CKEditor 5 module can not be
- // installed before the editor module since it depends on the editor module.
- // The installer does not have this limitation since it ensures that all of
- // the install profiles dependencies are installed before creating the
- // editor configuration.
- foreach (FilterFormat::loadMultiple() as $filter) {
- // Ensure that editor can be uninstalled by removing use in filter
- // formats. It is necessary to prime the filter collection before removing
- // the filter.
- $filter->filters();
- $filter->removeFilter('editor_file_reference');
- $filter->save();
- }
- \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']);
- $this->rebuildContainer();
- \Drupal::service('module_installer')->install(['editor']);
- /** @var \Drupal\contact\ContactFormInterface $contact_form */
- $contact_form = ContactForm::load('feedback');
- $recipients = $contact_form->getRecipients();
- $this->assertEquals(['simpletest@example.com'], $recipients);
-
- $role = Role::create([
- 'id' => 'admin_theme',
- 'label' => 'Admin theme',
- ]);
- $role->grantPermission('view the administration theme');
- $role->save();
- $this->adminUser->addRole($role->id());
- $this->adminUser->save();
- $this->drupalGet('node/add');
- $this->assertSession()->statusCodeEquals(200);
-
- // Ensure that there are no pending updates after installation.
- $this->drupalLogin($this->rootUser);
- $this->drupalGet('update.php/selection');
- $this->updateRequirementsProblem();
- $this->drupalGet('update.php/selection');
- $this->assertSession()->pageTextContains('No pending updates.');
-
- // Ensure that there are no pending entity updates after installation.
- $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.');
-
- // Make sure the optional image styles are not installed.
- $this->drupalGet('admin/config/media/image-styles');
- $this->assertSession()->pageTextNotContains('Max 325x325');
- $this->assertSession()->pageTextNotContains('Max 650x650');
- $this->assertSession()->pageTextNotContains('Max 1300x1300');
- $this->assertSession()->pageTextNotContains('Max 2600x2600');
-
- // Make sure the optional image styles are installed after enabling
- // the responsive_image module.
- \Drupal::service('module_installer')->install(['responsive_image']);
- $this->rebuildContainer();
- $this->drupalGet('admin/config/media/image-styles');
- $this->assertSession()->pageTextContains('Max 325x325');
- $this->assertSession()->pageTextContains('Max 650x650');
- $this->assertSession()->pageTextContains('Max 1300x1300');
- $this->assertSession()->pageTextContains('Max 2600x2600');
-
- // Make sure all image styles has webp conversion as last effect.
- foreach (ImageStyle::loadMultiple() as $style) {
- $effects = $style->getEffects()->getInstanceIds();
- $last = $style->getEffects()->get(end($effects));
- $this->assertSame('image_convert', $last->getConfiguration()['id']);
- $this->assertSame('webp', $last->getConfiguration()['data']['extension']);
- }
-
- // Verify certain routes' responses are cacheable by Dynamic Page Cache, to
- // ensure these responses are very fast for authenticated users.
- $this->drupalLogin($this->adminUser);
- $url = Url::fromRoute('contact.site_page');
- $this->drupalGet($url);
- // Verify that site-wide contact page cannot be cached by Dynamic Page
- // Cache.
- $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
-
- $url = Url::fromRoute('<front>');
- $this->drupalGet($url);
- $this->drupalGet($url);
- // Verify that frontpage is cached by Dynamic Page Cache.
- $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
- $url = Url::fromRoute('entity.node.canonical', ['node' => 1]);
- $this->drupalGet($url);
- $this->drupalGet($url);
- // Verify that full node page is cached by Dynamic Page Cache.
- $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
- $url = Url::fromRoute('entity.user.canonical', ['user' => 1]);
- $this->drupalGet($url);
- $this->drupalGet($url);
- // Verify that user profile page is cached by Dynamic Page Cache.
- $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
- // Make sure the editorial workflow is installed after enabling the
- // content_moderation module.
- \Drupal::service('module_installer')->install(['content_moderation']);
- $role = Role::create([
- 'id' => 'admin_workflows',
- 'label' => 'Admin workflow',
- ]);
- $role->grantPermission('administer workflows');
- $role->save();
- $this->adminUser->addRole($role->id());
- $this->adminUser->save();
- $this->rebuildContainer();
- $this->drupalGet('admin/config/workflow/workflows/manage/editorial');
- $this->assertSession()->pageTextContains('Draft');
- $this->assertSession()->pageTextContains('Published');
- $this->assertSession()->pageTextContains('Archived');
- $this->assertSession()->pageTextContains('Create New Draft');
- $this->assertSession()->pageTextContains('Publish');
- $this->assertSession()->pageTextContains('Archive');
- $this->assertSession()->pageTextContains('Restore to Draft');
- $this->assertSession()->pageTextContains('Restore');
-
- \Drupal::service('module_installer')->install(['media']);
- $role = Role::create([
- 'id' => 'admin_media',
- 'label' => 'Admin media',
- ]);
- $role->grantPermission('administer media');
- $role->grantPermission('administer media display');
- $role->save();
- $this->adminUser->addRole($role->id());
- $this->adminUser->save();
- $assert_session = $this->assertSession();
- $page = $this->getSession()->getPage();
- /** @var \Drupal\media\Entity\MediaType $media_type */
- foreach (MediaType::loadMultiple() as $media_type) {
- $media_type_machine_name = $media_type->id();
- $this->drupalGet('media/add/' . $media_type_machine_name);
- // Get the form element, and its HTML representation.
- $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form';
- $form = $assert_session->elementExists('css', $form_selector);
- $form_html = $form->getOuterHtml();
-
- // The name field should be hidden.
- $assert_session->fieldNotExists('Name', $form);
- // The source field should be shown before the vertical tabs.
- $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel();
- $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml();
- $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml();
- $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs));
- // The "Published" checkbox should be the last element.
- $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml();
- $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml();
- $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox));
- if (is_a($media_type->getSource(), Image::class, TRUE)) {
- // Assert the default entity view display is configured with an image
- // style.
- $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display');
- $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image');
- $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)');
- // By default for media types with an image source, only the image
- // component should be enabled.
- $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1);
- }
-
- }
-
- // Tests that user 1 does not have an all-access pass.
- $this->drupalLogin($this->rootUser);
- $this->drupalGet('admin');
- $this->assertSession()->statusCodeEquals(200);
-
- User::load(1)
- ->removeRole('administrator')
- ->save();
- // Clear caches so change take effect in system under test.
- $this->rebuildAll();
-
- $this->drupalGet('admin');
- $this->assertSession()->statusCodeEquals(403);
- }
-
}
diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
new file mode 100644
index 00000000000..e2e3c005219
--- /dev/null
+++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
@@ -0,0 +1,318 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\standard\Traits;
+
+use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
+use Drupal\Component\Utility\Html;
+use Drupal\contact\Entity\ContactForm;
+use Drupal\Core\Url;
+use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber;
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\image\Entity\ImageStyle;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\Plugin\media\Source\Image;
+use Drupal\Tests\RequirementsPageTrait;
+use Drupal\Tests\SchemaCheckTestTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Provides a test method to test the Standard installation profile or recipe.
+ */
+trait StandardTestTrait {
+ use SchemaCheckTestTrait;
+ use RequirementsPageTrait;
+
+ /**
+ * The admin user.
+ *
+ * @var \Drupal\user\UserInterface
+ */
+ protected $adminUser;
+
+ /**
+ * Tests Standard installation profile or recipe.
+ */
+ public function testStandard() {
+ $this->drupalGet('');
+ $this->assertSession()->pageTextContains('Powered by Drupal');
+ $this->assertSession()->pageTextContains('Congratulations and welcome to the Drupal community.');
+
+ // Test anonymous user can access 'Main navigation' block.
+ $this->adminUser = $this->drupalCreateUser([
+ 'administer blocks',
+ 'administer block content',
+ 'post comments',
+ 'skip comment approval',
+ 'create article content',
+ 'create page content',
+ ]);
+ $this->drupalLogin($this->adminUser);
+ // Configure the block.
+ $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero');
+ $this->submitForm([
+ 'region' => 'sidebar',
+ 'id' => 'main_navigation',
+ ], 'Save block');
+ // Verify admin user can see the block.
+ $this->drupalGet('');
+ $this->assertSession()->pageTextContains('Main navigation');
+
+ // Verify we have role = complementary on help_block blocks.
+ $this->drupalGet('admin/structure/block');
+ $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary');
+
+ // Verify anonymous user can see the block.
+ $this->drupalLogout();
+ $this->assertSession()->pageTextContains('Main navigation');
+
+ // Ensure comments don't show in the front page RSS feed.
+ // Create an article.
+ $this->drupalCreateNode([
+ 'type' => 'article',
+ 'title' => 'Foobar',
+ 'promote' => 1,
+ 'status' => 1,
+ 'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']],
+ ]);
+
+ // Add a comment.
+ $this->drupalLogin($this->adminUser);
+ $this->drupalGet('node/1');
+ // Verify that a line break is present.
+ $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me');
+ $this->submitForm([
+ 'subject[0][value]' => 'Bar foo',
+ 'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me',
+ ], 'Save');
+ // Fetch the feed.
+ $this->drupalGet('rss.xml');
+ $this->assertSession()->responseContains('Foobar');
+ $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me');
+
+ // Ensure block body exists.
+ $this->drupalGet('block/add');
+ $this->assertSession()->fieldExists('body[0][value]');
+
+ // Now we have all configuration imported, test all of them for schema
+ // conformance. Ensures all imported default configuration is valid when
+ // standard profile modules are enabled.
+ $names = $this->container->get('config.storage')->listAll();
+ /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */
+ $typed_config = $this->container->get('config.typed');
+ foreach ($names as $name) {
+ $config = $this->config($name);
+ $this->assertConfigSchema($typed_config, $name, $config->get());
+ }
+
+ // Validate all configuration.
+ // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373
+ foreach (Editor::loadMultiple() as $editor) {
+ // Currently only text editors using CKEditor 5 can be validated.
+ if ($editor->getEditor() !== 'ckeditor5') {
+ continue;
+ }
+
+ $this->assertSame([], array_map(
+ function (ConstraintViolation $v) {
+ return (string) $v->getMessage();
+ },
+ iterator_to_array(CKEditor5::validatePair(
+ $editor,
+ $editor->getFilterFormat()
+ ))
+ ));
+ }
+
+ // Ensure that configuration from the Standard profile is not reused when
+ // enabling a module again since it contains configuration that can not be
+ // installed. For example, editor.editor.basic_html is editor configuration
+ // that depends on the CKEditor 5 module. The CKEditor 5 module can not be
+ // installed before the editor module since it depends on the editor module.
+ // The installer does not have this limitation since it ensures that all of
+ // the install profiles dependencies are installed before creating the
+ // editor configuration.
+ foreach (FilterFormat::loadMultiple() as $filter) {
+ // Ensure that editor can be uninstalled by removing use in filter
+ // formats. It is necessary to prime the filter collection before removing
+ // the filter.
+ $filter->filters();
+ $filter->removeFilter('editor_file_reference');
+ $filter->save();
+ }
+ \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']);
+ $this->rebuildContainer();
+ \Drupal::service('module_installer')->install(['editor']);
+ /** @var \Drupal\contact\ContactFormInterface $contact_form */
+ $contact_form = ContactForm::load('feedback');
+ $recipients = $contact_form->getRecipients();
+ $this->assertEquals(['simpletest@example.com'], $recipients);
+
+ $role = Role::create([
+ 'id' => 'admin_theme',
+ 'label' => 'Admin theme',
+ ]);
+ $role->grantPermission('view the administration theme');
+ $role->save();
+ $this->adminUser->addRole($role->id());
+ $this->adminUser->save();
+ $this->drupalGet('node/add');
+ $this->assertSession()->statusCodeEquals(200);
+
+ // Ensure that there are no pending updates after installation.
+ $this->drupalLogin($this->rootUser);
+ $this->drupalGet('update.php/selection');
+ $this->updateRequirementsProblem();
+ $this->drupalGet('update.php/selection');
+ $this->assertSession()->pageTextContains('No pending updates.');
+
+ // Ensure that there are no pending entity updates after installation.
+ $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.');
+
+ // Make sure the optional image styles are not installed.
+ $this->drupalGet('admin/config/media/image-styles');
+ $this->assertSession()->pageTextNotContains('Max 325x325');
+ $this->assertSession()->pageTextNotContains('Max 650x650');
+ $this->assertSession()->pageTextNotContains('Max 1300x1300');
+ $this->assertSession()->pageTextNotContains('Max 2600x2600');
+
+ // Make sure the optional image styles are installed after enabling
+ // the responsive_image module.
+ $this->installResponsiveImage();
+ $this->drupalGet('admin/config/media/image-styles');
+ $this->assertSession()->pageTextContains('Max 325x325');
+ $this->assertSession()->pageTextContains('Max 650x650');
+ $this->assertSession()->pageTextContains('Max 1300x1300');
+ $this->assertSession()->pageTextContains('Max 2600x2600');
+
+ // Make sure all image styles has webp conversion as last effect.
+ foreach (ImageStyle::loadMultiple() as $style) {
+ $effects = $style->getEffects()->getInstanceIds();
+ $last = $style->getEffects()->get(end($effects));
+ $this->assertSame('image_convert', $last->getConfiguration()['id']);
+ $this->assertSame('webp', $last->getConfiguration()['data']['extension']);
+ }
+
+ // Verify certain routes' responses are cacheable by Dynamic Page Cache, to
+ // ensure these responses are very fast for authenticated users.
+ $this->drupalLogin($this->adminUser);
+ $url = Url::fromRoute('contact.site_page');
+ $this->drupalGet($url);
+ // Verify that site-wide contact page cannot be cached by Dynamic Page
+ // Cache.
+ $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+
+ $url = Url::fromRoute('<front>');
+ $this->drupalGet($url);
+ $this->drupalGet($url);
+ // Verify that frontpage is cached by Dynamic Page Cache.
+ $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+ $url = Url::fromRoute('entity.node.canonical', ['node' => 1]);
+ $this->drupalGet($url);
+ $this->drupalGet($url);
+ // Verify that full node page is cached by Dynamic Page Cache.
+ $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+ $url = Url::fromRoute('entity.user.canonical', ['user' => 1]);
+ $this->drupalGet($url);
+ $this->drupalGet($url);
+ // Verify that user profile page is cached by Dynamic Page Cache.
+ $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+ // Make sure the editorial workflow is installed after enabling the
+ // content_moderation module.
+ \Drupal::service('module_installer')->install(['content_moderation']);
+ $role = Role::create([
+ 'id' => 'admin_workflows',
+ 'label' => 'Admin workflow',
+ ]);
+ $role->grantPermission('administer workflows');
+ $role->save();
+ $this->adminUser->addRole($role->id());
+ $this->adminUser->save();
+ $this->rebuildContainer();
+ $this->drupalGet('admin/config/workflow/workflows/manage/editorial');
+ $this->assertSession()->pageTextContains('Draft');
+ $this->assertSession()->pageTextContains('Published');
+ $this->assertSession()->pageTextContains('Archived');
+ $this->assertSession()->pageTextContains('Create New Draft');
+ $this->assertSession()->pageTextContains('Publish');
+ $this->assertSession()->pageTextContains('Archive');
+ $this->assertSession()->pageTextContains('Restore to Draft');
+ $this->assertSession()->pageTextContains('Restore');
+
+ \Drupal::service('module_installer')->install(['media']);
+ $role = Role::create([
+ 'id' => 'admin_media',
+ 'label' => 'Admin media',
+ ]);
+ $role->grantPermission('administer media');
+ $role->grantPermission('administer media display');
+ $role->save();
+ $this->adminUser->addRole($role->id());
+ $this->adminUser->save();
+ $assert_session = $this->assertSession();
+ $page = $this->getSession()->getPage();
+ /** @var \Drupal\media\Entity\MediaType $media_type */
+ foreach (MediaType::loadMultiple() as $media_type) {
+ $media_type_machine_name = $media_type->id();
+ $this->drupalGet('media/add/' . $media_type_machine_name);
+ // Get the form element, and its HTML representation.
+ $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form';
+ $form = $assert_session->elementExists('css', $form_selector);
+ $form_html = $form->getOuterHtml();
+
+ // The name field should be hidden.
+ $assert_session->fieldNotExists('Name', $form);
+ // The source field should be shown before the vertical tabs.
+ $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel();
+ $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml();
+ $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml();
+ $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs));
+ // The "Published" checkbox should be the last element.
+ $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml();
+ $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml();
+ $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox));
+ if (is_a($media_type->getSource(), Image::class, TRUE)) {
+ // Assert the default entity view display is configured with an image
+ // style.
+ $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display');
+ $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image');
+ $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)');
+ // By default for media types with an image source, only the image
+ // component should be enabled.
+ $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1);
+ }
+
+ }
+
+ // Tests that user 1 does not have an all-access pass.
+ $this->drupalLogin($this->rootUser);
+ $this->drupalGet('admin');
+ $this->assertSession()->statusCodeEquals(200);
+
+ User::load(1)
+ ->removeRole('administrator')
+ ->save();
+ // Clear caches so change take effect in system under test.
+ $this->rebuildAll();
+
+ $this->drupalGet('admin');
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+ /**
+ * Installs the responsive image module.
+ */
+ protected function installResponsiveImage(): void {
+ // Install responsive_image module.
+ \Drupal::service('module_installer')->install(['responsive_image']);
+ $this->rebuildContainer();
+ }
+
+}
diff --git a/core/recipes/article_comment/config/field.field.node.article.comment.yml b/core/recipes/article_comment/config/field.field.node.article.comment.yml
new file mode 100644
index 00000000000..cf3b12af98f
--- /dev/null
+++ b/core/recipes/article_comment/config/field.field.node.article.comment.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.node.comment
+ - node.type.article
+ module:
+ - comment
+id: node.article.comment
+field_name: comment
+entity_type: node
+bundle: article
+label: Comments
+description: ''
+required: false
+translatable: true
+default_value:
+ -
+ status: 2
+ cid: 0
+ last_comment_timestamp: 0
+ last_comment_name: null
+ last_comment_uid: 0
+ comment_count: 0
+default_value_callback: ''
+settings:
+ default_mode: 1
+ per_page: 50
+ anonymous: 0
+ form_location: true
+ preview: 1
+field_type: comment
diff --git a/core/recipes/article_comment/recipe.yml b/core/recipes/article_comment/recipe.yml
new file mode 100644
index 00000000000..f4ac4e4be49
--- /dev/null
+++ b/core/recipes/article_comment/recipe.yml
@@ -0,0 +1,29 @@
+name: 'Article comments'
+description: 'Provides commenting on article content.'
+type: 'Content field'
+recipes:
+ - article_content_type
+ - comment_base
+config:
+ actions:
+ core.entity_form_display.node.article.default:
+ setComponent:
+ name: comment
+ options:
+ type: comment_default
+ weight: 20
+ region: content
+ settings: {}
+ third_party_settings: {}
+ core.entity_view_display.node.article.default:
+ setComponent:
+ name: comment
+ options:
+ type: comment_default
+ label: above
+ settings:
+ view_mode: default
+ pager_id: 0
+ third_party_settings: { }
+ weight: 110
+ region: content
diff --git a/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml
new file mode 100644
index 00000000000..f29f17bc046
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml
@@ -0,0 +1,87 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.node.article.body
+ - field.field.node.article.field_image
+ - image.style.thumbnail
+ - node.type.article
+ module:
+ - image
+ - path
+ - text
+id: node.article.default
+targetEntityType: node
+bundle: article
+mode: default
+content:
+ body:
+ type: text_textarea_with_summary
+ weight: 2
+ region: content
+ settings:
+ rows: 9
+ summary_rows: 3
+ placeholder: ''
+ show_summary: false
+ third_party_settings: { }
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_image:
+ type: image_image
+ weight: 1
+ region: content
+ settings:
+ progress_indicator: throbber
+ preview_image_style: thumbnail
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ promote:
+ type: boolean_checkbox
+ weight: 15
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 120
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ sticky:
+ type: boolean_checkbox
+ weight: 16
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ title:
+ type: string_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden: { }
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml
new file mode 100644
index 00000000000..a129e14dd6a
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.node.article.body
+ - field.field.node.article.field_image
+ - image.style.wide
+ - node.type.article
+ module:
+ - image
+ - text
+ - user
+id: node.article.default
+targetEntityType: node
+bundle: article
+mode: default
+content:
+ body:
+ type: text_default
+ label: hidden
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+ field_image:
+ type: image
+ label: hidden
+ settings:
+ image_style: wide
+ image_link: ''
+ image_loading:
+ attribute: eager
+ third_party_settings: { }
+ weight: -1
+ region: content
+ links:
+ settings: { }
+ third_party_settings: { }
+ weight: 100
+ region: content
+hidden: { }
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml
new file mode 100644
index 00000000000..05896dd3d74
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.node.rss
+ - field.field.node.article.body
+ - field.field.node.article.field_image
+ - node.type.article
+ module:
+ - user
+id: node.article.rss
+targetEntityType: node
+bundle: article
+mode: rss
+content:
+ links:
+ weight: 100
+ region: content
+hidden:
+ body: true
+ field_image: true
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml
new file mode 100644
index 00000000000..5ef60b519f4
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.node.teaser
+ - field.field.node.article.body
+ - field.field.node.article.field_image
+ - image.style.medium
+ - node.type.article
+ module:
+ - image
+ - text
+ - user
+id: node.article.teaser
+targetEntityType: node
+bundle: article
+mode: teaser
+content:
+ body:
+ type: text_summary_or_trimmed
+ label: hidden
+ settings:
+ trim_length: 600
+ third_party_settings: { }
+ weight: 0
+ region: content
+ field_image:
+ type: image
+ label: hidden
+ settings:
+ image_style: medium
+ image_link: content
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: -1
+ region: content
+ links:
+ weight: 100
+ region: content
+hidden: { }
diff --git a/core/recipes/article_content_type/config/field.field.node.article.body.yml b/core/recipes/article_content_type/config/field.field.node.article.body.yml
new file mode 100644
index 00000000000..b36fbd58449
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.field.node.article.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.node.body
+ - node.type.article
+ module:
+ - text
+id: node.article.body
+field_name: body
+entity_type: node
+bundle: article
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ display_summary: true
+ required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/article_content_type/config/field.field.node.article.field_image.yml b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml
new file mode 100644
index 00000000000..af4daeca6d0
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.node.field_image
+ - node.type.article
+ module:
+ - image
+id: node.article.field_image
+field_name: field_image
+entity_type: node
+bundle: article
+label: Image
+description: ''
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: '[date:custom:Y]-[date:custom:m]'
+ file_extensions: 'png gif jpg jpeg webp'
+ max_filesize: ''
+ max_resolution: ''
+ min_resolution: ''
+ alt_field: true
+ alt_field_required: true
+ title_field: false
+ title_field_required: false
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+field_type: image
diff --git a/core/recipes/article_content_type/config/field.storage.node.field_image.yml b/core/recipes/article_content_type/config/field.storage.node.field_image.yml
new file mode 100644
index 00000000000..a6964d3b0aa
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.storage.node.field_image.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - image
+ - node
+id: node.field_image
+field_name: field_image
+entity_type: node
+type: image
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes:
+ target_id:
+ - target_id
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/article_content_type/config/node.type.article.yml b/core/recipes/article_content_type/config/node.type.article.yml
new file mode 100644
index 00000000000..ae8e9d12580
--- /dev/null
+++ b/core/recipes/article_content_type/config/node.type.article.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies: { }
+name: Article
+type: article
+description: 'Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/recipes/article_content_type/recipe.yml b/core/recipes/article_content_type/recipe.yml
new file mode 100644
index 00000000000..30eaf30b121
--- /dev/null
+++ b/core/recipes/article_content_type/recipe.yml
@@ -0,0 +1,28 @@
+name: 'Article content type'
+description: 'Provides Article content type and related configuration. Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.'
+type: 'Content type'
+install:
+ - image
+ - node
+ - path
+config:
+ import:
+ node:
+ # Only import config which is also imported by the Standard profile.
+ - core.entity_view_mode.node.full
+ - core.entity_view_mode.node.rss
+ - core.entity_view_mode.node.teaser
+ - field.storage.node.body
+ - system.action.node_delete_action
+ - system.action.node_make_sticky_action
+ - system.action.node_make_unsticky_action
+ - system.action.node_promote_action
+ - system.action.node_publish_action
+ - system.action.node_save_action
+ - system.action.node_unpromote_action
+ - system.action.node_unpublish_action
+ image:
+ # Only import config which is also imported by the Standard profile.
+ - image.style.medium
+ - image.style.thumbnail
+ - image.style.wide
diff --git a/core/recipes/article_tags/config/field.field.node.article.field_tags.yml b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml
new file mode 100644
index 00000000000..1b9c4cc4ee1
--- /dev/null
+++ b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.node.field_tags
+ - node.type.article
+ - taxonomy.vocabulary.tags
+id: node.article.field_tags
+field_name: field_tags
+entity_type: node
+bundle: article
+label: Tags
+description: 'Enter a comma-separated list. For example: Amsterdam, Mexico City, "Cleveland, Ohio"'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:taxonomy_term'
+ handler_settings:
+ target_bundles:
+ tags: tags
+ sort:
+ field: _none
+ auto_create: true
+field_type: entity_reference
diff --git a/core/recipes/article_tags/config/field.storage.node.field_tags.yml b/core/recipes/article_tags/config/field.storage.node.field_tags.yml
new file mode 100644
index 00000000000..73f821f2c0d
--- /dev/null
+++ b/core/recipes/article_tags/config/field.storage.node.field_tags.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - node
+ - taxonomy
+id: node.field_tags
+field_name: field_tags
+entity_type: node
+type: entity_reference
+settings:
+ target_type: taxonomy_term
+module: core
+locked: false
+cardinality: -1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/article_tags/recipe.yml b/core/recipes/article_tags/recipe.yml
new file mode 100644
index 00000000000..59566afecb7
--- /dev/null
+++ b/core/recipes/article_tags/recipe.yml
@@ -0,0 +1,39 @@
+name: 'Article tags'
+description: 'Provides tags on article content.'
+type: 'Content field'
+recipes:
+ - article_content_type
+ - tags_taxonomy
+install:
+ - views
+config:
+ import:
+ taxonomy:
+ - views.view.taxonomy_term
+ actions:
+ core.entity_form_display.node.article.default:
+ setComponent:
+ name: field_tags
+ options:
+ type: entity_reference_autocomplete_tags
+ weight: 3
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ core.entity_view_display.node.article.teaser: &entity_view_display_node_article_teaser
+ setComponent:
+ name: field_tags
+ options:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 10
+ region: content
+ core.entity_view_display.node.article.default:
+ <<: *entity_view_display_node_article_teaser
diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml
new file mode 100644
index 00000000000..55854bcb88a
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.audio.field_media_audio_file
+ - media.type.audio
+ module:
+ - file
+ - path
+id: media.audio.default
+targetEntityType: media
+bundle: audio
+mode: default
+content:
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_media_audio_file:
+ type: file_generic
+ weight: 0
+ region: content
+ settings:
+ progress_indicator: throbber
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ name: true
diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml
new file mode 100644
index 00000000000..9179618e640
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.media_library
+ - field.field.media.audio.field_media_audio_file
+ - media.type.audio
+id: media.audio.media_library
+targetEntityType: media
+bundle: audio
+mode: media_library
+content: { }
+hidden:
+ created: true
+ field_media_audio_file: true
+ name: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml
new file mode 100644
index 00000000000..2956f691319
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.audio.field_media_audio_file
+ - media.type.audio
+ module:
+ - file
+id: media.audio.default
+targetEntityType: media
+bundle: audio
+mode: default
+content:
+ field_media_audio_file:
+ type: file_audio
+ label: visually_hidden
+ settings:
+ controls: true
+ autoplay: false
+ loop: false
+ multiple_file_display_type: tags
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ name: true
+ thumbnail: true
+ uid: true
diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml
new file mode 100644
index 00000000000..85dac7b2db4
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.media.media_library
+ - field.field.media.audio.field_media_audio_file
+ - image.style.thumbnail
+ - media.type.audio
+ module:
+ - image
+id: media.audio.media_library
+targetEntityType: media
+bundle: audio
+mode: media_library
+content:
+ thumbnail:
+ type: image
+ label: hidden
+ settings:
+ image_style: thumbnail
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ field_media_audio_file: true
+ name: true
+ uid: true
diff --git a/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml
new file mode 100644
index 00000000000..a4bb52eb859
--- /dev/null
+++ b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.media.field_media_audio_file
+ - media.type.audio
+ module:
+ - file
+id: media.audio.field_media_audio_file
+field_name: field_media_audio_file
+entity_type: media
+bundle: audio
+label: 'Audio file'
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: '[date:custom:Y]-[date:custom:m]'
+ file_extensions: 'mp3 wav aac'
+ max_filesize: ''
+ description_field: false
+field_type: file
diff --git a/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml
new file mode 100644
index 00000000000..1626b607eac
--- /dev/null
+++ b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - media
+id: media.field_media_audio_file
+field_name: field_media_audio_file
+entity_type: media
+type: file
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/audio_media_type/config/media.type.audio.yml b/core/recipes/audio_media_type/config/media.type.audio.yml
new file mode 100644
index 00000000000..233b2042dc4
--- /dev/null
+++ b/core/recipes/audio_media_type/config/media.type.audio.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: { }
+id: audio
+label: Audio
+description: 'A locally hosted audio file.'
+source: audio_file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+ source_field: field_media_audio_file
+field_map:
+ name: name
diff --git a/core/recipes/audio_media_type/recipe.yml b/core/recipes/audio_media_type/recipe.yml
new file mode 100644
index 00000000000..bd86d21d819
--- /dev/null
+++ b/core/recipes/audio_media_type/recipe.yml
@@ -0,0 +1,27 @@
+name: 'Audio media'
+description: 'Provides "Audio" media type and related configuration. A locally hosted audio file.'
+type: 'Media type'
+install:
+ - image
+ - media_library
+ - path
+ - views
+config:
+ import:
+ file:
+ - views.view.files
+ media_library:
+ - core.entity_view_display.media.audio.media_library
+ - core.entity_view_mode.media.media_library
+ - core.entity_form_mode.media.media_library
+ - image.style.media_library
+ - views.view.media_library
+ media:
+ - core.entity_view_mode.media.full
+ - system.action.media_delete_action
+ - system.action.media_publish_action
+ - system.action.media_save_action
+ - system.action.media_unpublish_action
+ - views.view.media
+ image:
+ - image.style.thumbnail
diff --git a/core/recipes/basic_block_type/config/block_content.type.basic.yml b/core/recipes/basic_block_type/config/block_content.type.basic.yml
new file mode 100644
index 00000000000..52ee4842410
--- /dev/null
+++ b/core/recipes/basic_block_type/config/block_content.type.basic.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: { }
+id: basic
+label: 'Basic block'
+revision: false
+description: 'A basic block contains a title and a body.'
diff --git a/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml
new file mode 100644
index 00000000000..fe49840e80b
--- /dev/null
+++ b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - block_content.type.basic
+ - field.field.block_content.basic.body
+ module:
+ - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+ body:
+ type: text_textarea_with_summary
+ weight: -4
+ region: content
+ settings:
+ rows: 9
+ summary_rows: 3
+ placeholder: ''
+ show_summary: false
+ third_party_settings: { }
+ info:
+ type: string_textfield
+ weight: -5
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden: { }
diff --git a/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml
new file mode 100644
index 00000000000..f4bb96567d4
--- /dev/null
+++ b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - block_content.type.basic
+ - field.field.block_content.basic.body
+ module:
+ - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+ body:
+ type: text_default
+ label: hidden
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden: { }
diff --git a/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml
new file mode 100644
index 00000000000..dab4f981810
--- /dev/null
+++ b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - block_content.type.basic
+ - field.storage.block_content.body
+ module:
+ - text
+id: block_content.basic.body
+field_name: body
+entity_type: block_content
+bundle: basic
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ display_summary: false
+ required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/basic_block_type/recipe.yml b/core/recipes/basic_block_type/recipe.yml
new file mode 100644
index 00000000000..d1d7fa8755e
--- /dev/null
+++ b/core/recipes/basic_block_type/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Basic block'
+description: 'Provides "Basic block" custom block type and related configuration. A basic block contains a title and a body.'
+type: 'Block type'
+install:
+ - block_content
+ - views
+config:
+ import:
+ block_content:
+ - field.storage.block_content.body
+ - core.entity_view_mode.block_content.full
+ - views.view.block_content
diff --git a/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml
new file mode 100644
index 00000000000..a31e41506fd
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml
@@ -0,0 +1,65 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.basic_html
+ module:
+ - ckeditor5
+format: basic_html
+editor: ckeditor5
+settings:
+ toolbar:
+ items:
+ - bold
+ - italic
+ - '|'
+ - link
+ - '|'
+ - bulletedList
+ - numberedList
+ - '|'
+ - blockQuote
+ - drupalInsertImage
+ - '|'
+ - heading
+ - code
+ - '|'
+ - sourceEditing
+ plugins:
+ ckeditor5_heading:
+ enabled_headings:
+ - heading2
+ - heading3
+ - heading4
+ - heading5
+ - heading6
+ ckeditor5_imageResize:
+ allow_resize: true
+ ckeditor5_list:
+ properties:
+ reversed: false
+ startIndex: true
+ multiBlock: true
+ ckeditor5_sourceEditing:
+ allowed_tags:
+ - '<cite>'
+ - '<dl>'
+ - '<dt>'
+ - '<dd>'
+ - '<a hreflang>'
+ - '<blockquote cite>'
+ - '<ul type>'
+ - '<ol type>'
+ - '<h2 id>'
+ - '<h3 id>'
+ - '<h4 id>'
+ - '<h5 id>'
+ - '<h6 id>'
+image_upload:
+ status: true
+ scheme: public
+ directory: inline-images
+ max_size: null
+ max_dimensions:
+ width: null
+ height: null
diff --git a/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml
new file mode 100644
index 00000000000..d81fc17303f
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml
@@ -0,0 +1,50 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - editor
+name: 'Basic HTML'
+format: basic_html
+weight: 0
+roles:
+ - authenticated
+filters:
+ editor_file_reference:
+ id: editor_file_reference
+ provider: editor
+ status: true
+ weight: 11
+ settings: { }
+ filter_align:
+ id: filter_align
+ provider: filter
+ status: true
+ weight: 7
+ settings: { }
+ filter_caption:
+ id: filter_caption
+ provider: filter
+ status: true
+ weight: 8
+ settings: { }
+ filter_html:
+ id: filter_html
+ provider: filter
+ status: true
+ weight: -10
+ settings:
+ allowed_html: '<br> <p> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <cite> <dl> <dt> <dd> <a hreflang href> <blockquote cite> <ul type> <ol start type> <strong> <em> <code> <li> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>'
+ filter_html_help: false
+ filter_html_nofollow: false
+ filter_html_image_secure:
+ id: filter_html_image_secure
+ provider: filter
+ status: true
+ weight: 9
+ settings: { }
+ filter_image_lazy_load:
+ id: filter_image_lazy_load
+ provider: filter
+ status: true
+ weight: 15
+ settings: { }
diff --git a/core/recipes/basic_html_format_editor/recipe.yml b/core/recipes/basic_html_format_editor/recipe.yml
new file mode 100644
index 00000000000..d1e5e6ec8ca
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/recipe.yml
@@ -0,0 +1,9 @@
+name: 'Basic HTML editor'
+description: 'Provides "Basic HTML" text format along with WYSIWYG editor and related configuration.'
+type: 'Text format editor'
+install:
+ - ckeditor5
+config:
+ actions:
+ user.role.authenticated:
+ grantPermission: 'use text format basic_html'
diff --git a/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml
new file mode 100644
index 00000000000..13985a85739
--- /dev/null
+++ b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml
@@ -0,0 +1,18 @@
+_meta:
+ version: '1.0'
+ entity_type: shortcut
+ uuid: 478b3170-1dfd-49d8-8eb3-f1b285445ab7
+ bundle: default
+ default_langcode: en
+default:
+ title:
+ -
+ value: 'All content'
+ weight:
+ -
+ value: -19
+ link:
+ -
+ uri: 'internal:/admin/content'
+ title: ''
+ options: { }
diff --git a/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml
new file mode 100644
index 00000000000..ad2dc75d687
--- /dev/null
+++ b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml
@@ -0,0 +1,18 @@
+_meta:
+ version: '1.0'
+ entity_type: shortcut
+ uuid: d5377721-d6de-4fdf-82e2-557c50f84ceb
+ bundle: default
+ default_langcode: en
+default:
+ title:
+ -
+ value: 'Add content'
+ weight:
+ -
+ value: -20
+ link:
+ -
+ uri: 'internal:/node/add'
+ title: ''
+ options: { }
diff --git a/core/recipes/basic_shortcuts/recipe.yml b/core/recipes/basic_shortcuts/recipe.yml
new file mode 100644
index 00000000000..8f52500eace
--- /dev/null
+++ b/core/recipes/basic_shortcuts/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Basic shortcuts'
+description: 'Provides a basic set of shortcuts for logged-in users.'
+type: Administration
+install:
+ - shortcut
+config:
+ import:
+ shortcut:
+ - shortcut.set.default
+ actions:
+ user.role.authenticated:
+ grantPermission: 'access shortcuts'
diff --git a/core/recipes/comment_base/config/comment.type.comment.yml b/core/recipes/comment_base/config/comment.type.comment.yml
new file mode 100644
index 00000000000..ddcbbc98609
--- /dev/null
+++ b/core/recipes/comment_base/config/comment.type.comment.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: { }
+id: comment
+label: 'Default comments'
+target_entity_type_id: node
+description: 'Allows commenting on content'
diff --git a/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml
new file mode 100644
index 00000000000..1010be29248
--- /dev/null
+++ b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml
@@ -0,0 +1,33 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - comment.type.comment
+ - field.field.comment.comment.comment_body
+ module:
+ - text
+id: comment.comment.default
+targetEntityType: comment
+bundle: comment
+mode: default
+content:
+ author:
+ weight: -2
+ region: content
+ comment_body:
+ type: text_textarea
+ weight: 11
+ region: content
+ settings:
+ rows: 5
+ placeholder: ''
+ third_party_settings: { }
+ subject:
+ type: string_textfield
+ weight: 10
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden: { }
diff --git a/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml
new file mode 100644
index 00000000000..b9fdd2bac71
--- /dev/null
+++ b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml
@@ -0,0 +1,24 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - comment.type.comment
+ - field.field.comment.comment.comment_body
+ module:
+ - text
+id: comment.comment.default
+targetEntityType: comment
+bundle: comment
+mode: default
+content:
+ comment_body:
+ type: text_default
+ label: hidden
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+ links:
+ weight: 100
+ region: content
+hidden: { }
diff --git a/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml
new file mode 100644
index 00000000000..1337070d16b
--- /dev/null
+++ b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - comment.type.comment
+ - field.storage.comment.comment_body
+ module:
+ - text
+id: comment.comment.comment_body
+field_name: comment_body
+entity_type: comment
+bundle: comment
+label: Comment
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: text_long
diff --git a/core/recipes/comment_base/config/field.storage.node.comment.yml b/core/recipes/comment_base/config/field.storage.node.comment.yml
new file mode 100644
index 00000000000..c5eee2c2841
--- /dev/null
+++ b/core/recipes/comment_base/config/field.storage.node.comment.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - comment
+ - node
+id: node.comment
+field_name: comment
+entity_type: node
+type: comment
+settings:
+ comment_type: comment
+module: comment
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/comment_base/recipe.yml b/core/recipes/comment_base/recipe.yml
new file mode 100644
index 00000000000..0cf967155a4
--- /dev/null
+++ b/core/recipes/comment_base/recipe.yml
@@ -0,0 +1,26 @@
+name: 'Default comments'
+description: 'Allows commenting on content.'
+type: 'Comment type'
+install:
+ - comment
+ - node
+ - views
+config:
+ import:
+ comment:
+ - core.entity_view_mode.comment.full
+ - field.storage.comment.comment_body
+ - system.action.comment_delete_action
+ - system.action.comment_publish_action
+ - system.action.comment_save_action
+ - system.action.comment_unpublish_action
+ - views.view.comment
+ - views.view.comments_recent
+ actions:
+ user.role.authenticated:
+ grantPermissions:
+ - 'access comments'
+ - 'post comments'
+ - 'skip comment approval'
+ user.role.anonymous:
+ grantPermission: 'access comments'
diff --git a/core/recipes/content_search/recipe.yml b/core/recipes/content_search/recipe.yml
new file mode 100644
index 00000000000..b9f60c59a7f
--- /dev/null
+++ b/core/recipes/content_search/recipe.yml
@@ -0,0 +1,19 @@
+name: 'Content search'
+type: Search
+description: 'Adds a page that can search site content.'
+install:
+ - node
+ - search
+config:
+ import:
+ node:
+ - core.entity_view_mode.node.search_index
+ - core.entity_view_mode.node.search_result
+ - search.page.node_search
+ actions:
+ user.role.anonymous:
+ grantPermissions:
+ - 'search content'
+ user.role.authenticated:
+ grantPermissions:
+ - 'search content'
diff --git a/core/recipes/core_recommended_admin_theme/recipe.yml b/core/recipes/core_recommended_admin_theme/recipe.yml
new file mode 100644
index 00000000000..5d60e2f5c9b
--- /dev/null
+++ b/core/recipes/core_recommended_admin_theme/recipe.yml
@@ -0,0 +1,24 @@
+name: 'Admin theme'
+description: 'Sets up Claro as the administrative (backend) theme.'
+type: 'Themes'
+install:
+ - claro
+ - block
+config:
+ import:
+ system:
+ - system.menu.account
+ - system.menu.main
+ - system.theme
+ claro:
+ - block.block.claro_breadcrumbs
+ - block.block.claro_content
+ - block.block.claro_local_actions
+ - block.block.claro_messages
+ - block.block.claro_page_title
+ - block.block.claro_primary_local_tasks
+ - block.block.claro_secondary_local_tasks
+ actions:
+ system.theme:
+ simple_config_update:
+ admin: claro
diff --git a/core/recipes/core_recommended_front_end_theme/recipe.yml b/core/recipes/core_recommended_front_end_theme/recipe.yml
new file mode 100644
index 00000000000..cdfb36b369e
--- /dev/null
+++ b/core/recipes/core_recommended_front_end_theme/recipe.yml
@@ -0,0 +1,29 @@
+name: 'Front end theme'
+description: 'Sets up Olivero as the front-end theme.'
+type: 'Themes'
+install:
+ - olivero
+ - block
+config:
+ import:
+ system:
+ - system.menu.account
+ - system.menu.main
+ - system.theme
+ olivero:
+ - block.block.olivero_account_menu
+ - block.block.olivero_breadcrumbs
+ - block.block.olivero_content
+ - block.block.olivero_main_menu
+ - block.block.olivero_messages
+ - block.block.olivero_page_title
+ - block.block.olivero_powered
+ - block.block.olivero_primary_admin_actions
+ - block.block.olivero_primary_local_tasks
+ - block.block.olivero_secondary_local_tasks
+ - block.block.olivero_site_branding
+ - core.date_format.olivero_medium
+ actions:
+ system.theme:
+ simple_config_update:
+ default: olivero
diff --git a/core/recipes/core_recommended_maintenance/recipe.yml b/core/recipes/core_recommended_maintenance/recipe.yml
new file mode 100644
index 00000000000..ef6ad24ee64
--- /dev/null
+++ b/core/recipes/core_recommended_maintenance/recipe.yml
@@ -0,0 +1,16 @@
+name: 'Recommended Maintenance'
+description: 'Sets up modules recommended for site maintenance.'
+type: 'Maintenance'
+install:
+ - automated_cron
+ - announcements_feed
+ - dblog
+ - views
+config:
+ import:
+ automated_cron:
+ - automated_cron.settings
+ dblog:
+ - views.view.watchdog
+ system:
+ - system.cron
diff --git a/core/recipes/core_recommended_performance/recipe.yml b/core/recipes/core_recommended_performance/recipe.yml
new file mode 100644
index 00000000000..0a60e3f26ca
--- /dev/null
+++ b/core/recipes/core_recommended_performance/recipe.yml
@@ -0,0 +1,7 @@
+name: 'Recommended Performance'
+description: 'Sets up modules for improved site performance.'
+type: 'Performance'
+install:
+ - page_cache
+ - dynamic_page_cache
+ - big_pipe
diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml
new file mode 100644
index 00000000000..7233f32e065
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.document.field_media_document
+ - media.type.document
+ module:
+ - file
+ - path
+id: media.document.default
+targetEntityType: media
+bundle: document
+mode: default
+content:
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_media_document:
+ type: file_generic
+ weight: 0
+ region: content
+ settings:
+ progress_indicator: throbber
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ name: true
diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml
new file mode 100644
index 00000000000..b7abbe903fe
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.media_library
+ - field.field.media.document.field_media_document
+ - media.type.document
+id: media.document.media_library
+targetEntityType: media
+bundle: document
+mode: media_library
+content: { }
+hidden:
+ created: true
+ field_media_document: true
+ name: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml
new file mode 100644
index 00000000000..0c443141107
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.document.field_media_document
+ - media.type.document
+ module:
+ - file
+id: media.document.default
+targetEntityType: media
+bundle: document
+mode: default
+content:
+ field_media_document:
+ type: file_default
+ label: visually_hidden
+ settings: { }
+ third_party_settings: { }
+ weight: 1
+ region: content
+hidden:
+ created: true
+ name: true
+ thumbnail: true
+ uid: true
diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml
new file mode 100644
index 00000000000..1f8eb004afe
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.media.media_library
+ - field.field.media.document.field_media_document
+ - image.style.thumbnail
+ - media.type.document
+ module:
+ - image
+id: media.document.media_library
+targetEntityType: media
+bundle: document
+mode: media_library
+content:
+ thumbnail:
+ type: image
+ label: hidden
+ settings:
+ image_style: thumbnail
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ field_media_document: true
+ name: true
+ uid: true
diff --git a/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml
new file mode 100644
index 00000000000..fb0b9909e2e
--- /dev/null
+++ b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.media.field_media_document
+ - media.type.document
+ module:
+ - file
+ enforced:
+ module:
+ - media
+id: media.document.field_media_document
+field_name: field_media_document
+entity_type: media
+bundle: document
+label: Document
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: '[date:custom:Y]-[date:custom:m]'
+ file_extensions: 'txt rtf doc docx ppt pptx xls xlsx pdf odf odg odp ods odt fodt fods fodp fodg key numbers pages'
+ max_filesize: ''
+ description_field: false
+field_type: file
diff --git a/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml
new file mode 100644
index 00000000000..309e509de09
--- /dev/null
+++ b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - media
+ enforced:
+ module:
+ - media
+id: media.field_media_document
+field_name: field_media_document
+entity_type: media
+type: file
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/document_media_type/config/media.type.document.yml b/core/recipes/document_media_type/config/media.type.document.yml
new file mode 100644
index 00000000000..35d7f1a60d5
--- /dev/null
+++ b/core/recipes/document_media_type/config/media.type.document.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: { }
+id: document
+label: Document
+description: 'An uploaded file or document, such as a PDF.'
+source: file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+ source_field: field_media_document
+field_map:
+ name: name
diff --git a/core/recipes/document_media_type/recipe.yml b/core/recipes/document_media_type/recipe.yml
new file mode 100644
index 00000000000..868610953e0
--- /dev/null
+++ b/core/recipes/document_media_type/recipe.yml
@@ -0,0 +1,25 @@
+name: 'Document media type'
+description: 'Provides "Document" media type and related configuration to enable uploaded files or documents, such as a PDF.'
+type: 'Media type'
+install:
+ - media_library
+ - path
+ - views
+config:
+ import:
+ file:
+ - views.view.files
+ media_library:
+ - core.entity_view_mode.media.media_library
+ - core.entity_form_mode.media.media_library
+ - image.style.media_library
+ - views.view.media_library
+ media:
+ - core.entity_view_mode.media.full
+ - system.action.media_delete_action
+ - system.action.media_publish_action
+ - system.action.media_save_action
+ - system.action.media_unpublish_action
+ - views.view.media
+ image:
+ - image.style.thumbnail
diff --git a/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml
new file mode 100644
index 00000000000..e462b2ab956
--- /dev/null
+++ b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml
@@ -0,0 +1,60 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - content_moderation
+id: editorial
+label: Editorial
+type: content_moderation
+type_settings:
+ states:
+ archived:
+ label: Archived
+ weight: 5
+ published: false
+ default_revision: true
+ draft:
+ label: Draft
+ weight: -5
+ published: false
+ default_revision: false
+ published:
+ label: Published
+ weight: 0
+ published: true
+ default_revision: true
+ transitions:
+ archive:
+ label: Archive
+ from:
+ - published
+ to: archived
+ weight: 2
+ archived_draft:
+ label: 'Restore to Draft'
+ from:
+ - archived
+ to: draft
+ weight: 3
+ archived_published:
+ label: Restore
+ from:
+ - archived
+ to: published
+ weight: 4
+ create_new_draft:
+ label: 'Create New Draft'
+ from:
+ - draft
+ - published
+ to: draft
+ weight: 0
+ publish:
+ label: Publish
+ from:
+ - draft
+ - published
+ to: published
+ weight: 1
+ entity_types: { }
+ default_moderation_state: draft
diff --git a/core/recipes/editorial_workflow/recipe.yml b/core/recipes/editorial_workflow/recipe.yml
new file mode 100644
index 00000000000..baa7f86de7f
--- /dev/null
+++ b/core/recipes/editorial_workflow/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Editorial workflow'
+description: 'Provides an editorial workflow for moderating content.'
+type: 'Workflow'
+install:
+ - content_moderation
+ # The moderated_content view depends on Node.
+ - node
+ - views
+config:
+ import:
+ content_moderation:
+ - views.view.moderated_content
diff --git a/core/recipes/example/composer.json b/core/recipes/example/composer.json
new file mode 100644
index 00000000000..1d231ba7eec
--- /dev/null
+++ b/core/recipes/example/composer.json
@@ -0,0 +1,9 @@
+{
+ "name": "drupal_recipe/example",
+ "description": "An example Drupal recipe description",
+ "type": "drupal-recipe",
+ "require": {
+ "drupal/core": "^10.0.x-dev"
+ },
+ "license": "GPL-2.0-or-later"
+}
diff --git a/core/recipes/example/recipe.yml b/core/recipes/example/recipe.yml
new file mode 100644
index 00000000000..6f53bebee39
--- /dev/null
+++ b/core/recipes/example/recipe.yml
@@ -0,0 +1,46 @@
+# The type key is similar to the package key in module.info.yml. It
+# can be used by the UI to group Drupal recipes. Additionally,
+# the type 'Site' means that the Drupal recipe will be listed in
+# the installer.
+type: 'Content type'
+
+install:
+ # An array of modules or themes to install, if they are not already.
+ # The system will detect if it is a theme or a module. During the
+ # install only simple configuration from the new modules is created.
+ # This allows the Drupal recipe control over the configuration.
+ - node
+ - text
+
+config:
+ # A Drupal recipe can have a config directory. All configuration
+ # is this directory will be imported after the modules have been
+ # installed.
+
+ # Additionally, the Drupal recipe can install configuration entities
+ # provided by the modules it configures. This allows them to not have
+ # to maintain or copy this configuration. Note the examples below are
+ # fictitious.
+ import:
+ node:
+ - node.type.article
+ # Import all configuration that is provided by the text module and any
+ # optional configuration that depends on the text module that is provided by
+ # modules already installed.
+ text: '*'
+
+ # Configuration actions may be defined. The structure here should be
+ # entity_type.ID.action. Below the user role entity type with an ID of
+ # editor is having the permissions added. The permissions key will be
+ # mapped to the \Drupal\user\Entity\Role::grantPermission() method.
+ actions:
+ user.role.editor:
+ ensure_exists:
+ label: 'Editor'
+ grantPermissions:
+ - 'delete any article content'
+ - 'edit any article content'
+
+content: {}
+# A Drupal recipe can have a content directory. All content in this
+# directory will be created after the configuration is installed.
diff --git a/core/recipes/feedback_contact_form/config/contact.form.feedback.yml b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml
new file mode 100644
index 00000000000..e222ecd4bff
--- /dev/null
+++ b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies: { }
+id: feedback
+label: 'Website feedback'
+recipients:
+ - admin@example.com
+reply: ''
+weight: 0
+message: 'Your message has been sent.'
+redirect: ''
diff --git a/core/recipes/feedback_contact_form/recipe.yml b/core/recipes/feedback_contact_form/recipe.yml
new file mode 100644
index 00000000000..c6368c6cfe5
--- /dev/null
+++ b/core/recipes/feedback_contact_form/recipe.yml
@@ -0,0 +1,24 @@
+name: 'Website feedback contact form'
+description: 'Provides a website feedback contact form.'
+type: 'Contact form'
+install:
+ - contact
+config:
+ import:
+ contact:
+ - contact.form.personal
+ system:
+ - system.menu.footer
+ actions:
+ core.menu.static_menu_link_overrides:
+ simple_config_update:
+ definitions.contact__site_page:
+ menu_name: footer
+ parent: ''
+ weight: 0
+ expanded: false
+ enabled: true
+ user.role.anonymous:
+ grantPermission: 'access site-wide contact form'
+ user.role.authenticated:
+ grantPermission: 'access site-wide contact form'
diff --git a/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml
new file mode 100644
index 00000000000..e30fc15eaf3
--- /dev/null
+++ b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml
@@ -0,0 +1,102 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - filter.format.full_html
+ module:
+ - ckeditor5
+format: full_html
+editor: ckeditor5
+settings:
+ toolbar:
+ items:
+ - bold
+ - italic
+ - strikethrough
+ - superscript
+ - subscript
+ - removeFormat
+ - '|'
+ - link
+ - '|'
+ - bulletedList
+ - numberedList
+ - '|'
+ - blockQuote
+ - drupalInsertImage
+ - insertTable
+ - horizontalLine
+ - '|'
+ - heading
+ - codeBlock
+ - '|'
+ - sourceEditing
+ plugins:
+ ckeditor5_codeBlock:
+ languages:
+ -
+ label: 'Plain text'
+ language: plaintext
+ -
+ label: C
+ language: c
+ -
+ label: 'C#'
+ language: cs
+ -
+ label: C++
+ language: cpp
+ -
+ label: CSS
+ language: css
+ -
+ label: Diff
+ language: diff
+ -
+ label: HTML
+ language: html
+ -
+ label: Java
+ language: java
+ -
+ label: JavaScript
+ language: javascript
+ -
+ label: PHP
+ language: php
+ -
+ label: Python
+ language: python
+ -
+ label: Ruby
+ language: ruby
+ -
+ label: TypeScript
+ language: typescript
+ -
+ label: XML
+ language: xml
+ ckeditor5_heading:
+ enabled_headings:
+ - heading2
+ - heading3
+ - heading4
+ - heading5
+ - heading6
+ ckeditor5_imageResize:
+ allow_resize: true
+ ckeditor5_list:
+ properties:
+ reversed: true
+ startIndex: true
+ multiBlock: true
+ ckeditor5_sourceEditing:
+ allowed_tags: { }
+image_upload:
+ status: true
+ scheme: public
+ directory: inline-images
+ max_size: null
+ max_dimensions:
+ width: null
+ height: null
diff --git a/core/recipes/full_html_format_editor/config/filter.format.full_html.yml b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml
new file mode 100644
index 00000000000..a0e616a4989
--- /dev/null
+++ b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - editor
+name: 'Full HTML'
+format: full_html
+weight: 2
+roles:
+ - administrator
+filters:
+ editor_file_reference:
+ id: editor_file_reference
+ provider: editor
+ status: true
+ weight: 11
+ settings: { }
+ filter_align:
+ id: filter_align
+ provider: filter
+ status: true
+ weight: 8
+ settings: { }
+ filter_caption:
+ id: filter_caption
+ provider: filter
+ status: true
+ weight: 9
+ settings: { }
+ filter_htmlcorrector:
+ id: filter_htmlcorrector
+ provider: filter
+ status: true
+ weight: 10
+ settings: { }
+ filter_image_lazy_load:
+ id: filter_image_lazy_load
+ provider: filter
+ status: true
+ weight: 15
+ settings: { }
diff --git a/core/recipes/full_html_format_editor/recipe.yml b/core/recipes/full_html_format_editor/recipe.yml
new file mode 100644
index 00000000000..171c75ac7b8
--- /dev/null
+++ b/core/recipes/full_html_format_editor/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Full HTML editor'
+description: 'Provides "Full HTML" text format along with WYSIWYG editor and related configuration.'
+type: 'Text format editor'
+install:
+ - ckeditor5
diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml
new file mode 100644
index 00000000000..1d7a19b5932
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml
@@ -0,0 +1,54 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.image.field_media_image
+ - image.style.thumbnail
+ - media.type.image
+ module:
+ - image
+ - path
+id: media.image.default
+targetEntityType: media
+bundle: image
+mode: default
+content:
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_media_image:
+ type: image_image
+ weight: 0
+ region: content
+ settings:
+ progress_indicator: throbber
+ preview_image_style: thumbnail
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ name: true
diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml
new file mode 100644
index 00000000000..63babd6f80d
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.media_library
+ - field.field.media.image.field_media_image
+ - image.style.thumbnail
+ - media.type.image
+ module:
+ - image
+id: media.image.media_library
+targetEntityType: media
+bundle: image
+mode: media_library
+content:
+ field_media_image:
+ type: image_image
+ weight: 1
+ region: content
+ settings:
+ progress_indicator: throbber
+ preview_image_style: thumbnail
+ third_party_settings: { }
+hidden:
+ created: true
+ name: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml
new file mode 100644
index 00000000000..e2b85e90ac2
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml
@@ -0,0 +1,30 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.image.field_media_image
+ - image.style.large
+ - media.type.image
+ module:
+ - image
+id: media.image.default
+targetEntityType: media
+bundle: image
+mode: default
+content:
+ field_media_image:
+ type: image
+ label: visually_hidden
+ settings:
+ image_style: large
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 1
+ region: content
+hidden:
+ created: true
+ name: true
+ thumbnail: true
+ uid: true
diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml
new file mode 100644
index 00000000000..15469d4def1
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.media.media_library
+ - field.field.media.image.field_media_image
+ - image.style.medium
+ - media.type.image
+ module:
+ - image
+id: media.image.media_library
+targetEntityType: media
+bundle: image
+mode: media_library
+content:
+ thumbnail:
+ type: image
+ label: hidden
+ settings:
+ image_style: medium
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ field_media_image: true
+ name: true
+ uid: true
diff --git a/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml
new file mode 100644
index 00000000000..2e2cebf91fe
--- /dev/null
+++ b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml
@@ -0,0 +1,40 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.media.field_media_image
+ - media.type.image
+ module:
+ - image
+ enforced:
+ module:
+ - media
+id: media.image.field_media_image
+field_name: field_media_image
+entity_type: media
+bundle: image
+label: Image
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: '[date:custom:Y]-[date:custom:m]'
+ file_extensions: 'png gif jpg jpeg webp'
+ max_filesize: ''
+ max_resolution: ''
+ min_resolution: ''
+ alt_field: true
+ alt_field_required: true
+ title_field: false
+ title_field_required: false
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+field_type: image
diff --git a/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml
new file mode 100644
index 00000000000..59a6fbe1429
--- /dev/null
+++ b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - image
+ - media
+ enforced:
+ module:
+ - media
+id: media.field_media_image
+field_name: field_media_image
+entity_type: media
+type: image
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/image_media_type/config/media.type.image.yml b/core/recipes/image_media_type/config/media.type.image.yml
new file mode 100644
index 00000000000..b92ef428c3a
--- /dev/null
+++ b/core/recipes/image_media_type/config/media.type.image.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: { }
+id: image
+label: Image
+description: 'Use local images for reusable media.'
+source: image
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+ source_field: field_media_image
+field_map:
+ name: name
diff --git a/core/recipes/image_media_type/recipe.yml b/core/recipes/image_media_type/recipe.yml
new file mode 100644
index 00000000000..9fe21630494
--- /dev/null
+++ b/core/recipes/image_media_type/recipe.yml
@@ -0,0 +1,27 @@
+name: 'Image media type'
+description: 'Provides "Image" media type and related configuration. Use local images for reusable media.'
+type: 'Media type'
+install:
+ - media_library
+ - path
+ - views
+config:
+ import:
+ file:
+ - views.view.files
+ media_library:
+ - core.entity_view_mode.media.media_library
+ - core.entity_form_mode.media.media_library
+ - image.style.media_library
+ - views.view.media_library
+ media:
+ - core.entity_view_mode.media.full
+ - system.action.media_delete_action
+ - system.action.media_publish_action
+ - system.action.media_save_action
+ - system.action.media_unpublish_action
+ - views.view.media
+ image:
+ - image.style.medium
+ - image.style.large
+ - image.style.thumbnail
diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml
new file mode 100644
index 00000000000..e3fdffe0dab
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.video.field_media_video_file
+ - media.type.video
+ module:
+ - file
+ - path
+id: media.video.default
+targetEntityType: media
+bundle: video
+mode: default
+content:
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_media_video_file:
+ type: file_generic
+ weight: 0
+ region: content
+ settings:
+ progress_indicator: throbber
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ name: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml
new file mode 100644
index 00000000000..db2cf0b19e9
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.media_library
+ - field.field.media.video.field_media_video_file
+ - media.type.video
+id: media.video.media_library
+targetEntityType: media
+bundle: video
+mode: media_library
+content: { }
+hidden:
+ created: true
+ field_media_video_file: true
+ name: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml
new file mode 100644
index 00000000000..3c26f17aae3
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.video.field_media_video_file
+ - media.type.video
+ module:
+ - file
+id: media.video.default
+targetEntityType: media
+bundle: video
+mode: default
+content:
+ field_media_video_file:
+ type: file_video
+ label: visually_hidden
+ settings:
+ controls: true
+ autoplay: false
+ loop: false
+ multiple_file_display_type: tags
+ muted: false
+ width: 640
+ height: 480
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ name: true
+ thumbnail: true
+ uid: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml
new file mode 100644
index 00000000000..a4db1a99b8e
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.media.media_library
+ - field.field.media.video.field_media_video_file
+ - image.style.thumbnail
+ - media.type.video
+ module:
+ - image
+id: media.video.media_library
+targetEntityType: media
+bundle: video
+mode: media_library
+content:
+ thumbnail:
+ type: image
+ label: hidden
+ settings:
+ image_style: thumbnail
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ field_media_video_file: true
+ name: true
+ uid: true
diff --git a/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml
new file mode 100644
index 00000000000..b6c0be146e4
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.media.field_media_video_file
+ - media.type.video
+ module:
+ - file
+id: media.video.field_media_video_file
+field_name: field_media_video_file
+entity_type: media
+bundle: video
+label: 'Video file'
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: '[date:custom:Y]-[date:custom:m]'
+ file_extensions: mp4
+ max_filesize: ''
+ description_field: false
+field_type: file
diff --git a/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml
new file mode 100644
index 00000000000..0ac96a14b7c
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - media
+id: media.field_media_video_file
+field_name: field_media_video_file
+entity_type: media
+type: file
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/local_video_media_type/config/media.type.video.yml b/core/recipes/local_video_media_type/config/media.type.video.yml
new file mode 100644
index 00000000000..b07ac27059a
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/media.type.video.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: { }
+id: video
+label: Video
+description: 'A locally hosted video file.'
+source: video_file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+ source_field: field_media_video_file
+field_map:
+ name: name
diff --git a/core/recipes/local_video_media_type/recipe.yml b/core/recipes/local_video_media_type/recipe.yml
new file mode 100644
index 00000000000..8bb1c6f6f63
--- /dev/null
+++ b/core/recipes/local_video_media_type/recipe.yml
@@ -0,0 +1,25 @@
+name: 'Local video media'
+description: 'Provides a media type for self-hosted video files.'
+type: 'Media type'
+install:
+ - media_library
+ - path
+ - views
+config:
+ import:
+ file:
+ - views.view.files
+ media_library:
+ - core.entity_view_mode.media.media_library
+ - core.entity_form_mode.media.media_library
+ - image.style.media_library
+ - views.view.media_library
+ media:
+ - core.entity_view_mode.media.full
+ - system.action.media_delete_action
+ - system.action.media_publish_action
+ - system.action.media_save_action
+ - system.action.media_unpublish_action
+ - views.view.media
+ image:
+ - image.style.thumbnail
diff --git a/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml
new file mode 100644
index 00000000000..27226a1a6f2
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - node.type.page
+id: node.page.promote
+field_name: promote
+entity_type: node
+bundle: page
+label: 'Promoted to front page'
+description: ''
+required: false
+translatable: false
+default_value:
+ -
+ value: 0
+default_value_callback: ''
+settings:
+ on_label: 'On'
+ off_label: 'Off'
+field_type: boolean
diff --git a/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml
new file mode 100644
index 00000000000..edb853ed3de
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml
@@ -0,0 +1,76 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.node.page.body
+ - node.type.page
+ module:
+ - path
+ - text
+id: node.page.default
+targetEntityType: node
+bundle: page
+mode: default
+content:
+ body:
+ type: text_textarea_with_summary
+ weight: 31
+ region: content
+ settings:
+ rows: 9
+ summary_rows: 3
+ placeholder: ''
+ show_summary: false
+ third_party_settings: { }
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ promote:
+ type: boolean_checkbox
+ weight: 15
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 120
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ sticky:
+ type: boolean_checkbox
+ weight: 16
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ title:
+ type: string_textfield
+ weight: -5
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden: { }
diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml
new file mode 100644
index 00000000000..bd70482cd1d
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.node.page.body
+ - node.type.page
+ module:
+ - text
+ - user
+id: node.page.default
+targetEntityType: node
+bundle: page
+mode: default
+content:
+ body:
+ type: text_default
+ label: hidden
+ settings: { }
+ third_party_settings: { }
+ weight: 100
+ region: content
+ links:
+ weight: 101
+ region: content
+hidden: { }
diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml
new file mode 100644
index 00000000000..34a70d932fa
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml
@@ -0,0 +1,27 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.node.teaser
+ - field.field.node.page.body
+ - node.type.page
+ module:
+ - text
+ - user
+id: node.page.teaser
+targetEntityType: node
+bundle: page
+mode: teaser
+content:
+ body:
+ type: text_summary_or_trimmed
+ label: hidden
+ settings:
+ trim_length: 600
+ third_party_settings: { }
+ weight: 100
+ region: content
+ links:
+ weight: 101
+ region: content
+hidden: { }
diff --git a/core/recipes/page_content_type/config/field.field.node.page.body.yml b/core/recipes/page_content_type/config/field.field.node.page.body.yml
new file mode 100644
index 00000000000..4ff17d0e711
--- /dev/null
+++ b/core/recipes/page_content_type/config/field.field.node.page.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.node.body
+ - node.type.page
+ module:
+ - text
+id: node.page.body
+field_name: body
+entity_type: node
+bundle: page
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ display_summary: true
+ required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/page_content_type/config/node.type.page.yml b/core/recipes/page_content_type/config/node.type.page.yml
new file mode 100644
index 00000000000..755e8ed8ce5
--- /dev/null
+++ b/core/recipes/page_content_type/config/node.type.page.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies: { }
+name: 'Basic page'
+type: page
+description: "Use <em>basic pages</em> for your static content, such as an 'About us' page."
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: false
diff --git a/core/recipes/page_content_type/recipe.yml b/core/recipes/page_content_type/recipe.yml
new file mode 100644
index 00000000000..e9193c024f2
--- /dev/null
+++ b/core/recipes/page_content_type/recipe.yml
@@ -0,0 +1,21 @@
+name: 'Basic page'
+description: "Provides Basic page content type and related configuration. Use <em>basic pages</em> for your static content, such as an 'About us' page."
+type: 'Content type'
+install:
+ - node
+ - path
+config:
+ import:
+ node:
+ - core.entity_view_mode.node.full
+ - core.entity_view_mode.node.rss
+ - core.entity_view_mode.node.teaser
+ - field.storage.node.body
+ - system.action.node_delete_action
+ - system.action.node_make_sticky_action
+ - system.action.node_make_unsticky_action
+ - system.action.node_promote_action
+ - system.action.node_publish_action
+ - system.action.node_save_action
+ - system.action.node_unpromote_action
+ - system.action.node_unpublish_action
diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml
new file mode 100644
index 00000000000..0f57855dd0a
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml
@@ -0,0 +1,53 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.remote_video.field_media_oembed_video
+ - media.type.remote_video
+ module:
+ - media
+ - path
+id: media.remote_video.default
+targetEntityType: media
+bundle: remote_video
+mode: default
+content:
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_media_oembed_video:
+ type: oembed_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+ uid:
+ type: entity_reference_autocomplete
+ weight: 5
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+hidden:
+ name: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml
new file mode 100644
index 00000000000..6a1461cded7
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.media.media_library
+ - field.field.media.remote_video.field_media_oembed_video
+ - media.type.remote_video
+id: media.remote_video.media_library
+targetEntityType: media
+bundle: remote_video
+mode: media_library
+content: { }
+hidden:
+ created: true
+ field_media_oembed_video: true
+ name: true
+ path: true
+ status: true
+ uid: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml
new file mode 100644
index 00000000000..5dd5a52d6cc
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.media.remote_video.field_media_oembed_video
+ - media.type.remote_video
+ module:
+ - media
+id: media.remote_video.default
+targetEntityType: media
+bundle: remote_video
+mode: default
+content:
+ field_media_oembed_video:
+ type: oembed
+ label: hidden
+ settings:
+ max_width: 0
+ max_height: 0
+ loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ name: true
+ thumbnail: true
+ uid: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml
new file mode 100644
index 00000000000..268b1b37fe0
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.media.media_library
+ - field.field.media.remote_video.field_media_oembed_video
+ - image.style.medium
+ - media.type.remote_video
+ module:
+ - image
+id: media.remote_video.media_library
+targetEntityType: media
+bundle: remote_video
+mode: media_library
+content:
+ thumbnail:
+ type: image
+ label: hidden
+ settings:
+ image_style: medium
+ image_link: ''
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ created: true
+ field_media_oembed_video: true
+ name: true
+ uid: true
diff --git a/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml
new file mode 100644
index 00000000000..6ff378fa17a
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml
@@ -0,0 +1,18 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.media.field_media_oembed_video
+ - media.type.remote_video
+id: media.remote_video.field_media_oembed_video
+field_name: field_media_oembed_video
+entity_type: media
+bundle: remote_video
+label: 'Video URL'
+description: ''
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml
new file mode 100644
index 00000000000..e8664f0b181
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - media
+id: media.field_media_oembed_video
+field_name: field_media_oembed_video
+entity_type: media
+type: string
+settings:
+ max_length: 255
+ case_sensitive: false
+ is_ascii: false
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/remote_video_media_type/config/media.type.remote_video.yml b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml
new file mode 100644
index 00000000000..203d6983276
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies: { }
+id: remote_video
+label: 'Remote video'
+description: 'A remotely hosted video from YouTube or Vimeo.'
+source: 'oembed:video'
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+ source_field: field_media_oembed_video
+ thumbnails_directory: 'public://oembed_thumbnails/[date:custom:Y-m]'
+ providers:
+ - YouTube
+ - Vimeo
+field_map:
+ title: name
diff --git a/core/recipes/remote_video_media_type/recipe.yml b/core/recipes/remote_video_media_type/recipe.yml
new file mode 100644
index 00000000000..1f66ebfc199
--- /dev/null
+++ b/core/recipes/remote_video_media_type/recipe.yml
@@ -0,0 +1,23 @@
+name: 'Remote video media'
+description: 'Provides a media type for videos hosted on YouTube and Vimeo.'
+type: 'Media type'
+install:
+ - media_library
+ - path
+ - views
+config:
+ import:
+ media_library:
+ - core.entity_view_mode.media.media_library
+ - core.entity_form_mode.media.media_library
+ - image.style.media_library
+ - views.view.media_library
+ media:
+ - core.entity_view_mode.media.full
+ - system.action.media_delete_action
+ - system.action.media_publish_action
+ - system.action.media_save_action
+ - system.action.media_unpublish_action
+ - views.view.media
+ image:
+ - image.style.medium
diff --git a/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml
new file mode 100644
index 00000000000..5656b145148
--- /dev/null
+++ b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies: { }
+name: 'Restricted HTML'
+format: restricted_html
+weight: 1
+filters:
+ filter_html:
+ id: filter_html
+ provider: filter
+ status: true
+ weight: -10
+ settings:
+ allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>'
+ filter_html_help: true
+ filter_html_nofollow: false
+ filter_autop:
+ id: filter_autop
+ provider: filter
+ status: true
+ weight: 0
+ settings: { }
+ filter_url:
+ id: filter_url
+ provider: filter
+ status: true
+ weight: 0
+ settings:
+ filter_url_length: 72
diff --git a/core/recipes/restricted_html_format/recipe.yml b/core/recipes/restricted_html_format/recipe.yml
new file mode 100644
index 00000000000..8aec764918f
--- /dev/null
+++ b/core/recipes/restricted_html_format/recipe.yml
@@ -0,0 +1,11 @@
+name: 'Restricted HTML'
+description: 'Provides "Restricted HTML" text format.'
+type: 'Text format'
+install:
+ - filter
+config:
+ import:
+ filter: '*'
+ actions:
+ user.role.anonymous:
+ grantPermission: 'use text format restricted_html'
diff --git a/core/recipes/standard/config/user.role.administrator.yml b/core/recipes/standard/config/user.role.administrator.yml
new file mode 100644
index 00000000000..ca48a58b4ee
--- /dev/null
+++ b/core/recipes/standard/config/user.role.administrator.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: { }
+id: administrator
+label: Administrator
+weight: 3
+is_admin: true
+permissions: { }
diff --git a/core/recipes/standard/config/user.role.content_editor.yml b/core/recipes/standard/config/user.role.content_editor.yml
new file mode 100644
index 00000000000..b1dbe10170a
--- /dev/null
+++ b/core/recipes/standard/config/user.role.content_editor.yml
@@ -0,0 +1,23 @@
+langcode: en
+status: true
+dependencies: { }
+id: content_editor
+label: 'Content editor'
+weight: 2
+is_admin: false
+permissions:
+ - 'access administration pages'
+ - 'access content overview'
+ - 'access contextual links'
+ - 'access files overview'
+ - 'access toolbar'
+ - 'administer url aliases'
+ - 'create terms in tags'
+ - 'create url aliases'
+ - 'edit own comments'
+ - 'edit terms in tags'
+ - 'delete own files'
+ - 'revert all revisions'
+ - 'view all revisions'
+ - 'view own unpublished content'
+ - 'view the administration theme'
diff --git a/core/recipes/standard/recipe.yml b/core/recipes/standard/recipe.yml
new file mode 100644
index 00000000000..ae577ff8a90
--- /dev/null
+++ b/core/recipes/standard/recipe.yml
@@ -0,0 +1,85 @@
+name: 'Standard'
+description: 'Provides a standard site with commonly used features pre-configured.'
+type: 'Site'
+recipes:
+ - basic_block_type
+ - basic_shortcuts
+ - article_comment
+ - article_tags
+ - feedback_contact_form
+ - article_content_type
+ - page_content_type
+ - basic_html_format_editor
+ - full_html_format_editor
+ - content_search
+ - core_recommended_performance
+ - core_recommended_maintenance
+ - core_recommended_admin_theme
+ - core_recommended_front_end_theme
+ - user_picture
+ # Provides a fallback text format which is available to all users.
+ - restricted_html_format
+install:
+ - image
+ - help
+ - history
+ - config
+ - contextual
+ - menu_link_content
+ - datetime
+ - menu_ui
+ - options
+ - toolbar
+ - field_ui
+ - views_ui
+ - shortcut
+config:
+ import:
+ claro:
+ - block.block.claro_help
+ help:
+ - search.page.help_search
+ - block.block.claro_help_search
+ image:
+ - image.style.large
+ - image.style.thumbnail
+ node:
+ - views.view.archive
+ - views.view.content
+ - views.view.content_recent
+ - views.view.frontpage
+ - views.view.glossary
+ olivero:
+ - block.block.olivero_help
+ - block.block.olivero_search_form_narrow
+ - block.block.olivero_search_form_wide
+ - block.block.olivero_syndicate
+ user:
+ - core.entity_view_mode.user.compact
+ - search.page.user_search
+ - views.view.user_admin_people
+ - views.view.who_s_new
+ - views.view.who_s_online
+ actions:
+ node.settings:
+ simple_config_update:
+ use_admin_theme: true
+ system.site:
+ simple_config_update:
+ page.front: /node
+ user.role.authenticated:
+ grantPermission: 'delete own files'
+ user.role.content_editor:
+ grantPermissionsForEachNodeType:
+ - 'create %bundle content'
+ - 'delete %bundle revisions'
+ - 'delete own %bundle content'
+ - 'edit own %bundle content'
+ user.role.anonymous:
+ # This recipe assumes all published content should be publicly accessible.
+ grantPermission: 'access content'
+ user.settings:
+ simple_config_update:
+ verify_mail: true
+ register: visitors_admin_approval
+ cancel_method: user_cancel_block
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
new file mode 100644
index 00000000000..fde3282498d
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+ module:
+ - responsive_image
+ enforced:
+ module:
+ - responsive_image
+name: max_1300x1300
+label: 'Max 1300x1300'
+effects:
+ 04caae9a-fa3e-4ea6-ae09-9c26aec7d308:
+ uuid: 04caae9a-fa3e-4ea6-ae09-9c26aec7d308
+ id: image_scale
+ weight: 1
+ data:
+ width: 1300
+ height: 1300
+ upscale: false
+ e8c9d6ba-a017-4a87-9999-7ce52e138e1d:
+ uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d
+ id: image_convert
+ weight: 2
+ data:
+ extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
new file mode 100644
index 00000000000..a63e72ab6f3
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+ module:
+ - responsive_image
+ enforced:
+ module:
+ - responsive_image
+name: max_2600x2600
+label: 'Max 2600x2600'
+effects:
+ 9b311dd1-0351-45a1-9500-cd069e4670cb:
+ uuid: 9b311dd1-0351-45a1-9500-cd069e4670cb
+ id: image_scale
+ weight: 1
+ data:
+ width: 2600
+ height: 2600
+ upscale: false
+ 3c42f186-7beb-4dbf-b720-bff9dfeaa677:
+ uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677
+ id: image_convert
+ weight: 2
+ data:
+ extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
new file mode 100644
index 00000000000..e820c8bb01d
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+ module:
+ - responsive_image
+ enforced:
+ module:
+ - responsive_image
+name: max_325x325
+label: 'Max 325x325'
+effects:
+ cb842cc8-682f-42a6-bd05-5a1ac726f0d8:
+ uuid: cb842cc8-682f-42a6-bd05-5a1ac726f0d8
+ id: image_scale
+ weight: 1
+ data:
+ width: 325
+ height: 325
+ upscale: false
+ f2b6c795-26ae-4130-aa18-aa120ea3ba98:
+ uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98
+ id: image_convert
+ weight: 2
+ data:
+ extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
new file mode 100644
index 00000000000..d5beda6259f
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+ module:
+ - responsive_image
+ enforced:
+ module:
+ - responsive_image
+name: max_650x650
+label: 'Max 650x650'
+effects:
+ 949c201a-77f5-48f6-ba00-be91eb1aad47:
+ uuid: 949c201a-77f5-48f6-ba00-be91eb1aad47
+ id: image_scale
+ weight: 1
+ data:
+ width: 650
+ height: 650
+ upscale: false
+ 4a2a7af8-8ea3-419d-b5f8-256d57016102:
+ uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102
+ id: image_convert
+ weight: 2
+ data:
+ extension: webp
diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml
new file mode 100644
index 00000000000..51590cd7b20
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - image.style.max_1300x1300
+ - image.style.max_325x325
+ - image.style.max_650x650
+id: narrow
+label: Narrow
+image_style_mappings:
+ -
+ image_mapping_type: sizes
+ image_mapping:
+ sizes: '(min-width: 1290px) 325px, (min-width: 851px) 25vw, (min-width: 560px) 50vw, 100vw'
+ sizes_image_styles:
+ - max_1300x1300
+ - max_650x650
+ - max_325x325
+ breakpoint_id: responsive_image.viewport_sizing
+ multiplier: 1x
+breakpoint_group: responsive_image
+fallback_image_style: max_325x325
diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml
new file mode 100644
index 00000000000..06cb8a98e80
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml
@@ -0,0 +1,24 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - image.style.max_1300x1300
+ - image.style.max_2600x2600
+ - image.style.max_325x325
+ - image.style.max_650x650
+id: wide
+label: Wide
+image_style_mappings:
+ -
+ image_mapping_type: sizes
+ image_mapping:
+ sizes: '(min-width: 1290px) 1290px, 100vw'
+ sizes_image_styles:
+ - max_2600x2600
+ - max_1300x1300
+ - max_650x650
+ - max_325x325
+ breakpoint_id: responsive_image.viewport_sizing
+ multiplier: 1x
+breakpoint_group: responsive_image
+fallback_image_style: max_325x325
diff --git a/core/recipes/standard_responsive_images/recipe.yml b/core/recipes/standard_responsive_images/recipe.yml
new file mode 100644
index 00000000000..210a3286908
--- /dev/null
+++ b/core/recipes/standard_responsive_images/recipe.yml
@@ -0,0 +1,8 @@
+name: 'Standard Responsive Images'
+description: 'Provides basic responsive images and accompanying image styles.'
+type: 'Media'
+install:
+ - responsive_image
+config:
+ import:
+ image: '*'
diff --git a/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml
new file mode 100644
index 00000000000..4c754e86c71
--- /dev/null
+++ b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: { }
+name: Tags
+vid: tags
+description: 'Use tags to group articles on similar topics into categories.'
+weight: 0
diff --git a/core/recipes/tags_taxonomy/recipe.yml b/core/recipes/tags_taxonomy/recipe.yml
new file mode 100644
index 00000000000..46436718a88
--- /dev/null
+++ b/core/recipes/tags_taxonomy/recipe.yml
@@ -0,0 +1,11 @@
+name: Tags
+description: 'Provides "Tags" taxonomy vocabulary and related configuration. Use tags to group content on similar topics into categories.'
+type: 'Taxonomy'
+install:
+ - taxonomy
+config:
+ import:
+ taxonomy:
+ - core.entity_view_mode.taxonomy_term.full
+ - system.action.taxonomy_term_publish_action
+ - system.action.taxonomy_term_unpublish_action
diff --git a/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml
new file mode 100644
index 00000000000..8098d4688a6
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml
@@ -0,0 +1,35 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.user.user.user_picture
+ - image.style.thumbnail
+ module:
+ - image
+ - user
+id: user.user.default
+targetEntityType: user
+bundle: user
+mode: default
+content:
+ account:
+ weight: -10
+ region: content
+ contact:
+ weight: 5
+ region: content
+ language:
+ weight: 0
+ region: content
+ timezone:
+ weight: 6
+ region: content
+ user_picture:
+ type: image_image
+ weight: -1
+ region: content
+ settings:
+ progress_indicator: throbber
+ preview_image_style: thumbnail
+ third_party_settings: { }
+hidden: { }
diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml
new file mode 100644
index 00000000000..1e0ea7c9f7e
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml
@@ -0,0 +1,28 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.user.compact
+ - field.field.user.user.user_picture
+ - image.style.thumbnail
+ module:
+ - image
+ - user
+id: user.user.compact
+targetEntityType: user
+bundle: user
+mode: compact
+content:
+ user_picture:
+ type: image
+ label: hidden
+ settings:
+ image_style: thumbnail
+ image_link: content
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ member_for: true
diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml
new file mode 100644
index 00000000000..9bc86dc8906
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.user.user.user_picture
+ - image.style.thumbnail
+ module:
+ - image
+ - user
+id: user.user.default
+targetEntityType: user
+bundle: user
+mode: default
+content:
+ member_for:
+ weight: 5
+ region: content
+ user_picture:
+ type: image
+ label: hidden
+ settings:
+ image_style: thumbnail
+ image_link: content
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden: { }
diff --git a/core/recipes/user_picture/config/field.field.user.user.user_picture.yml b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml
new file mode 100644
index 00000000000..54a59c087f8
--- /dev/null
+++ b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.user.user_picture
+ module:
+ - image
+ - user
+id: user.user.user_picture
+field_name: user_picture
+entity_type: user
+bundle: user
+label: Picture
+description: 'Your virtual face or picture.'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:file'
+ handler_settings: { }
+ file_directory: 'pictures/[date:custom:Y]-[date:custom:m]'
+ file_extensions: 'png gif jpg jpeg webp'
+ max_filesize: ''
+ max_resolution: ''
+ min_resolution: ''
+ alt_field: false
+ alt_field_required: false
+ title_field: false
+ title_field_required: false
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+field_type: image
diff --git a/core/recipes/user_picture/config/field.storage.user.user_picture.yml b/core/recipes/user_picture/config/field.storage.user.user_picture.yml
new file mode 100644
index 00000000000..6d0476df6c2
--- /dev/null
+++ b/core/recipes/user_picture/config/field.storage.user.user_picture.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - file
+ - image
+ - user
+id: user.user_picture
+field_name: user_picture
+entity_type: user
+type: image
+settings:
+ target_type: file
+ display_field: false
+ display_default: false
+ uri_scheme: public
+ default_image:
+ uuid: null
+ alt: ''
+ title: ''
+ width: null
+ height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes:
+ target_id:
+ - target_id
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/user_picture/recipe.yml b/core/recipes/user_picture/recipe.yml
new file mode 100644
index 00000000000..ba84c830020
--- /dev/null
+++ b/core/recipes/user_picture/recipe.yml
@@ -0,0 +1,8 @@
+name: User pictures
+description: 'Adds the ability for user accounts to have pictures (avatars).'
+type: Users
+install:
+ - field
+ - file
+ - image
+ - user
diff --git a/core/scripts/drupal b/core/scripts/drupal
index 891d5b81178..0c9eb300cde 100644
--- a/core/scripts/drupal
+++ b/core/scripts/drupal
@@ -10,6 +10,7 @@ use Drupal\Core\Command\GenerateTheme;
use Drupal\Core\Command\QuickStartCommand;
use Drupal\Core\Command\InstallCommand;
use Drupal\Core\Command\ServerCommand;
+use Drupal\Core\Recipe\RecipeCommand;
use Symfony\Component\Console\Application;
if (PHP_SAPI !== 'cli') {
@@ -24,5 +25,6 @@ $application->add(new QuickStartCommand());
$application->add(new InstallCommand($classloader));
$application->add(new ServerCommand($classloader));
$application->add(new GenerateTheme());
+$application->add(new RecipeCommand($classloader));
$application->run();
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php
new file mode 100644
index 00000000000..f1efd741390
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Tests applying all core-provided recipes on top of the Empty profile.
+ *
+ * @group Recipe
+ */
+class CoreRecipesTest extends BrowserTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $profile = 'minimal';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * The data provider for apply recipe test.
+ *
+ * @return iterable<array<string>>
+ * An iterable containing paths to recipe files.
+ */
+ public static function providerApplyRecipe(): iterable {
+ $finder = Finder::create()
+ ->in([
+ static::getDrupalRoot() . '/core/recipes',
+ ])
+ ->directories()
+ // Recipes can't contain other recipes, so we don't need to search in
+ // subdirectories.
+ ->depth(0)
+ // The Example recipe is for documentation only, and cannot be applied.
+ ->notName(['example']);
+
+ $scenarios = [];
+ /** @var \Symfony\Component\Finder\SplFileInfo $recipe */
+ foreach ($finder as $recipe) {
+ $name = $recipe->getBasename();
+ $scenarios[$name] = [
+ $recipe->getPathname(),
+ ];
+ }
+ return $scenarios;
+ }
+
+ /**
+ * Test the recipe apply.
+ *
+ * @param string $path
+ * The path to the recipe file.
+ *
+ * @dataProvider providerApplyRecipe
+ */
+ public function testApplyRecipe(string $path): void {
+ $this->setUpCurrentUser(admin: TRUE);
+ $this->applyRecipe($path);
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
new file mode 100644
index 00000000000..39149729e78
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand
+ * @group Recipe
+ *
+ * BrowserTestBase is used for a proper Drupal install.
+ */
+class RecipeCommandTest extends BrowserTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * {@inheritdoc}
+ *
+ * Disable strict config schema because this test explicitly makes the
+ * recipe system save invalid config, to prove that it validates it after
+ * the fact and raises an error.
+ */
+ protected $strictConfigSchema = FALSE;
+
+ public function testRecipeCommand(): void {
+ $this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed');
+ $this->assertCheckpointsExist([]);
+
+ $process = $this->applyRecipe('core/tests/fixtures/recipes/install_node_with_config');
+ $this->assertSame(0, $process->getExitCode());
+ $this->assertStringContainsString("Applied Install node with config recipe.", $process->getErrorOutput());
+ $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput());
+ $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
+ $this->assertCheckpointsExist(["Backup before the 'Install node with config' recipe."]);
+
+ // Ensure recipes can be applied without affecting pre-existing checkpoints.
+ $process = $this->applyRecipe('core/tests/fixtures/recipes/install_two_modules');
+ $this->assertSame(0, $process->getExitCode());
+ $this->assertStringContainsString("Applied Install two modules recipe.", $process->getErrorOutput());
+ $this->assertStringContainsString('Install two modules applied successfully', $process->getOutput());
+ $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
+ $this->assertCheckpointsExist([
+ "Backup before the 'Install node with config' recipe.",
+ "Backup before the 'Install two modules' recipe.",
+ ]);
+
+ // Ensure recipes that fail have an exception message.
+ $process = $this->applyRecipe('core/tests/fixtures/recipes/invalid_config', 1);
+ $this->assertStringContainsString("There were validation errors in core.date_format.invalid", $process->getErrorOutput());
+ $this->assertCheckpointsExist([
+ "Backup before the 'Install node with config' recipe.",
+ "Backup before the 'Install two modules' recipe.",
+ // Although the recipe command tried to create a checkpoint, it did not
+ // actually happen, because of https://drupal.org/i/3408523.
+ ]);
+
+ // Create a checkpoint so we can test what happens when a recipe does not
+ // create a checkpoint before applying.
+ \Drupal::service('config.storage.checkpoint')->checkpoint('Test log message');
+ $process = $this->applyRecipe('core/tests/fixtures/recipes/no_extensions');
+ $this->assertSame(0, $process->getExitCode());
+ $this->assertStringContainsString("Applied No extensions recipe.", $process->getErrorOutput());
+ $this->assertCheckpointsExist([
+ "Backup before the 'Install node with config' recipe.",
+ "Backup before the 'Install two modules' recipe.",
+ "Test log message",
+ ]);
+ $this->assertStringContainsString('[notice] A backup checkpoint was not created because nothing has changed since the "Test log message" checkpoint was created.', $process->getOutput());
+ }
+
+ /**
+ * Tests that errors during config rollback won't steamroll validation errors.
+ */
+ public function testExceptionOnRollback(): void {
+ $process = $this->applyRecipe('core/tests/fixtures/recipes/config_rollback_exception', 1);
+
+ // The error from the config importer should be visible.
+ $output = $process->getOutput();
+ $this->assertStringContainsString('There were errors validating the config synchronization.', $output);
+ $this->assertStringContainsString('Provides a filter plugin that is in use', $output);
+ // And the exception that actually *caused* the error should be visible too.
+ $this->assertStringContainsString('There were validation errors in system.image:', $process->getErrorOutput());
+ }
+
+ /**
+ * Asserts that the current set of checkpoints matches the given labels.
+ *
+ * @param string[] $expected_labels
+ * The labels of every checkpoint that is expected to exist currently, in
+ * the expected order.
+ */
+ private function assertCheckpointsExist(array $expected_labels): void {
+ $checkpoints = \Drupal::service('config.checkpoints');
+ $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
+ $this->assertSame($expected_labels, array_values($labels));
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php
new file mode 100644
index 00000000000..601dced2963
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Contains helper methods for interacting with recipes in functional tests.
+ */
+trait RecipeTestTrait {
+
+ /**
+ * Creates a recipe in a temporary directory.
+ *
+ * @param string|array<mixed> $data
+ * The contents of recipe.yml. If passed as an array, will be encoded to
+ * YAML.
+ * @param string|null $machine_name
+ * The machine name for the recipe. Will be used as the directory name.
+ *
+ * @return \Drupal\Core\Recipe\Recipe
+ * The recipe object.
+ */
+ protected function createRecipe(string|array $data, ?string $machine_name = NULL): Recipe {
+ if (is_array($data)) {
+ $data = Yaml::encode($data);
+ }
+ $recipes_dir = $this->siteDirectory . '/recipes';
+ if ($machine_name === NULL) {
+ $dir = uniqid($recipes_dir . '/');
+ }
+ else {
+ $dir = $recipes_dir . '/' . $machine_name;
+ }
+ mkdir($dir, recursive: TRUE);
+ file_put_contents($dir . '/recipe.yml', $data);
+
+ return Recipe::createFromDirectory($dir);
+ }
+
+ /**
+ * Applies a recipe to the site.
+ *
+ * @param string $path
+ * The path of the recipe to apply. Must be a directory.
+ * @param int $expected_exit_code
+ * The expected exit code of the `drupal recipe` process. Defaults to 0,
+ * which indicates that no error occurred.
+ *
+ * @return \Symfony\Component\Process\Process
+ * The `drupal recipe` command process, after having run.
+ */
+ protected function applyRecipe(string $path, int $expected_exit_code = 0): Process {
+ assert($this instanceof BrowserTestBase);
+ $this->assertDirectoryExists($path);
+
+ $arguments = [
+ (new PhpExecutableFinder())->find(),
+ 'core/scripts/drupal',
+ 'recipe',
+ $path,
+ ];
+ $process = (new Process($arguments))
+ ->setWorkingDirectory($this->getDrupalRoot())
+ ->setEnv([
+ 'DRUPAL_DEV_SITE_PATH' => $this->siteDirectory,
+ // Ensure that the command boots Drupal into a state where it knows it's
+ // a test site.
+ // @see drupal_valid_test_ua()
+ 'HTTP_USER_AGENT' => drupal_generate_test_ua($this->databasePrefix),
+ ])
+ ->setTimeout(500);
+
+ $process->run();
+ $this->assertSame($expected_exit_code, $process->getExitCode(), $process->getErrorOutput());
+ // Applying a recipe:
+ // - creates new checkpoints, hence the "state" service in the test runner
+ // is outdated
+ // - may install modules, which would cause the entire container in the test
+ // runner to be outdated.
+ // Hence the entire environment must be rebuilt for assertions to target the
+ // actual post-recipe-application result.
+ // @see \Drupal\Core\Config\Checkpoint\LinearHistory::__construct()
+ $this->rebuildAll();
+ return $process;
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php
new file mode 100644
index 00000000000..e7509aa8528
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\FunctionalTests\Installer\InstallerTestBase;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\Tests\standard\Traits\StandardTestTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Yaml\Yaml as SymfonyYaml;
+
+/**
+ * Tests installing the Standard recipe via the installer.
+ *
+ * @group #slow
+ * @group Recipe
+ */
+class StandardRecipeInstallTest extends InstallerTestBase {
+ use StandardTestTrait {
+ testStandard as doTestStandard;
+ }
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $profile = '';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ // Skip permissions hardening so we can write a services file later.
+ $this->settings['settings']['skip_permissions_hardening'] = (object) [
+ 'value' => TRUE,
+ 'required' => TRUE,
+ ];
+
+ parent::setUp();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function visitInstaller(): void {
+ // Use a URL to install from a recipe.
+ $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?profile=&recipe=core/recipes/standard');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function testStandard(): void {
+ if (!isset($this->rootUser->passRaw) && isset($this->rootUser->pass_raw)) {
+ $this->rootUser->passRaw = $this->rootUser->pass_raw;
+ }
+ // These recipes provide functionality that is only optionally part of the
+ // Standard profile, so we need to explicitly apply them.
+ $this->applyRecipe('core/recipes/editorial_workflow');
+ $this->applyRecipe('core/recipes/audio_media_type');
+ $this->applyRecipe('core/recipes/document_media_type');
+ $this->applyRecipe('core/recipes/image_media_type');
+ $this->applyRecipe('core/recipes/local_video_media_type');
+ $this->applyRecipe('core/recipes/remote_video_media_type');
+
+ // Add a Home link to the main menu as Standard expects "Main navigation"
+ // block on the page.
+ $this->drupalGet('admin/structure/menu/manage/main/add');
+ $this->submitForm([
+ 'title[0][value]' => 'Home',
+ 'link[0][uri]' => '<front>',
+ ], 'Save');
+
+ // Standard expects to set the contact form's recipient email to the
+ // system's email address, but our feedback_contact_form recipe hard-codes
+ // it to another value.
+ // @todo This can be removed after https://drupal.org/i/3303126, which
+ // should make it possible for a recipe to reuse an already-set config
+ // value.
+ ContactForm::load('feedback')?->setRecipients(['simpletest@example.com'])
+ ->save();
+
+ // Standard ships two shortcuts; ensure they exist.
+ $this->assertCount(2, Shortcut::loadMultiple());
+
+ // The installer logs you in.
+ $this->drupalLogout();
+
+ $this->doTestStandard();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUpProfile(): void {
+ // Noop. This form is skipped due the parameters set on the URL.
+ }
+
+ protected function installDefaultThemeFromClassProperty(ContainerInterface $container): void {
+ // In this context a default theme makes no sense.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function installResponsiveImage(): void {
+ // Overrides StandardTest::installResponsiveImage() in order to use the
+ // recipe.
+ $this->applyRecipe('core/recipes/standard_responsive_images');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUpSite(): void {
+ $services_file = DRUPAL_ROOT . '/' . $this->siteDirectory . '/services.yml';
+ // $content = file_get_contents($services_file);
+
+ // Disable the super user access.
+ $yaml = new SymfonyYaml();
+ $services = [];
+ $services['parameters']['security.enable_super_user'] = FALSE;
+ file_put_contents($services_file, $yaml->dump($services));
+ parent::setUpSite();
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php
new file mode 100644
index 00000000000..2e66805c898
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\Tests\standard\Functional\StandardTest;
+use Drupal\user\RoleInterface;
+
+/**
+ * Tests Standard recipe installation expectations.
+ *
+ * @group #slow
+ * @group Recipe
+ */
+class StandardRecipeTest extends StandardTest {
+
+ use RecipeTestTrait;
+
+ /**
+ * Tests Standard installation recipe.
+ */
+ public function testStandard(): void {
+ // Install some modules that Standard has optional integrations with.
+ \Drupal::service('module_installer')->install(['media_library', 'content_moderation']);
+
+ // Export all the configuration so we can compare later.
+ $this->copyConfig(\Drupal::service('config.storage'), \Drupal::service('config.storage.sync'));
+
+ // Set theme to stark and uninstall the other themes.
+ $theme_installer = \Drupal::service('theme_installer');
+ $theme_installer->install(['stark']);
+ $this->config('system.theme')->set('admin', '')->set('default', 'stark')->save();
+ $theme_installer->uninstall(['claro', 'olivero']);
+
+ // Determine which modules to uninstall.
+ $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]);
+ foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) {
+ $storage = \Drupal::entityTypeManager()->getStorage($entity_type);
+ $storage->delete($storage->loadMultiple());
+ }
+
+ // Uninstall all the modules including the Standard profile.
+ \Drupal::service('module_installer')->uninstall($uninstall);
+
+ // Clean up entity displays before recipe import.
+ foreach (['entity_form_display', 'entity_view_display'] as $entity_type) {
+ $storage = \Drupal::entityTypeManager()->getStorage($entity_type);
+ $storage->delete($storage->loadMultiple());
+ }
+
+ // Clean up roles before recipe import.
+ $storage = \Drupal::entityTypeManager()->getStorage('user_role');
+ $roles = $storage->loadMultiple();
+ // Do not delete the administrator role. There would be no user with the
+ // permissions to create content.
+ unset($roles[RoleInterface::ANONYMOUS_ID], $roles[RoleInterface::AUTHENTICATED_ID], $roles['administrator']);
+ $storage->delete($roles);
+
+ $this->applyRecipe('core/recipes/standard');
+ // These recipes provide functionality that is only optionally part of the
+ // Standard profile, so we need to explicitly apply them.
+ $this->applyRecipe('core/recipes/editorial_workflow');
+ $this->applyRecipe('core/recipes/audio_media_type');
+ $this->applyRecipe('core/recipes/document_media_type');
+ $this->applyRecipe('core/recipes/image_media_type');
+ $this->applyRecipe('core/recipes/local_video_media_type');
+ $this->applyRecipe('core/recipes/remote_video_media_type');
+
+ // Remove the theme we had to install.
+ \Drupal::service('theme_installer')->uninstall(['stark']);
+
+ // Add a Home link to the main menu as Standard expects "Main navigation"
+ // block on the page.
+ $this->drupalLogin($this->rootUser);
+ $this->drupalGet('admin/structure/menu/manage/main/add');
+ $this->submitForm([
+ 'title[0][value]' => 'Home',
+ 'link[0][uri]' => '<front>',
+ ], 'Save');
+
+ // Standard expects to set the contact form's recipient email to the
+ // system's email address, but our feedback_contact_form recipe hard-codes
+ // it to another value.
+ // @todo This can be removed after https://drupal.org/i/3303126, which
+ // should make it possible for a recipe to reuse an already-set config
+ // value.
+ ContactForm::load('feedback')?->setRecipients(['simpletest@example.com'])
+ ->save();
+
+ // Update sync directory config to have the same UUIDs so we can compare.
+ /** @var \Drupal\Core\Config\StorageInterface $sync */
+ $sync = \Drupal::service('config.storage.sync');
+ /** @var \Drupal\Core\Config\StorageInterface $active */
+ $active = \Drupal::service('config.storage');
+ // @todo https://www.drupal.org/i/3439749 Determine if the the _core unset
+ // is correct.
+ foreach ($active->listAll() as $name) {
+ /** @var mixed[] $active_data */
+ $active_data = $active->read($name);
+ if ($sync->exists($name)) {
+ /** @var mixed[] $sync_data */
+ $sync_data = $sync->read($name);
+ if (isset($sync_data['uuid'])) {
+ $sync_data['uuid'] = $active_data['uuid'];
+ }
+ if (isset($sync_data['_core'])) {
+ unset($sync_data['_core']);
+ }
+ /** @var array $sync_data */
+ $sync->write($name, $sync_data);
+ }
+ if (isset($active_data['_core'])) {
+ unset($active_data['_core']);
+ $active->write($name, $active_data);
+ }
+ // @todo Remove this once https://drupal.org/i/3427564 lands.
+ if ($name === 'node.settings') {
+ unset($active_data['langcode']);
+ $active->write($name, $active_data);
+ }
+ }
+
+ // Ensure we have truly rebuilt the standard profile using recipes.
+ // Uncomment the code below to see the differences in a single file.
+ // $this->assertSame($sync->read('node.settings'), $active->read('node.settings'));
+ $comparer = $this->configImporter()->getStorageComparer();
+ $expected_list = $comparer->getEmptyChangelist();
+ // We expect core.extension to be different because standard is no longer
+ // installed.
+ $expected_list['update'] = ['core.extension'];
+ $this->assertSame($expected_list, $comparer->getChangelist());
+
+ // Standard ships two shortcuts; ensure they exist.
+ $this->assertCount(2, Shortcut::loadMultiple());
+
+ parent::testStandard();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function installResponsiveImage(): void {
+ // Overrides StandardTest::installResponsiveImage() in order to use the
+ // recipe.
+ $this->applyRecipe('core/recipes/standard_responsive_images');
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php
new file mode 100644
index 00000000000..a4191b29cc1
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php
@@ -0,0 +1,263 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\DefaultContent;
+
+use ColinODell\PsrTestLogger\TestLogger;
+use Drupal\block_content\BlockContentInterface;
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\DefaultContent\Existing;
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\DefaultContent\Importer;
+use Drupal\Core\DefaultContent\ImportException;
+use Drupal\Core\DefaultContent\InvalidEntityException;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\File\FileExists;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\file\FileInterface;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\language\Entity\ContentLanguageSettings;
+use Drupal\media\MediaInterface;
+use Drupal\menu_link_content\MenuLinkContentInterface;
+use Drupal\node\NodeInterface;
+use Drupal\taxonomy\TermInterface;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Psr\Log\LogLevel;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\Importer
+ * @group DefaultContent
+ * @group Recipe
+ */
+class ContentImportTest extends BrowserTestBase {
+
+ use EntityReferenceFieldCreationTrait;
+ use MediaTypeCreationTrait;
+ use RecipeTestTrait;
+ use TaxonomyTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'block_content',
+ 'content_translation',
+ 'entity_test',
+ 'media',
+ 'menu_link_content',
+ 'node',
+ 'path',
+ 'path_alias',
+ 'system',
+ 'taxonomy',
+ 'user',
+ ];
+
+ private readonly string $contentDir;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->setUpCurrentUser(admin: TRUE);
+
+ BlockContentType::create(['id' => 'basic', 'label' => 'Basic'])->save();
+ block_content_add_body_field('basic');
+
+ $this->createVocabulary(['vid' => 'tags']);
+ $this->createMediaType('image', ['id' => 'image']);
+ $this->drupalCreateContentType(['type' => 'page']);
+ $this->drupalCreateContentType(['type' => 'article']);
+ $this->createEntityReferenceField('node', 'article', 'field_tags', 'Tags', 'taxonomy_term');
+
+ // Create a field with custom serialization, so we can ensure that the
+ // importer handles that properly.
+ $field_storage = FieldStorageConfig::create([
+ 'entity_type' => 'taxonomy_term',
+ 'field_name' => 'field_serialized_stuff',
+ 'type' => 'serialized_property_item_test',
+ ]);
+ $field_storage->save();
+ FieldConfig::create([
+ 'field_storage' => $field_storage,
+ 'bundle' => 'tags',
+ ])->save();
+
+ ConfigurableLanguage::createFromLangcode('fr')->save();
+ ContentLanguageSettings::create([
+ 'target_entity_type_id' => 'node',
+ 'target_bundle' => 'article',
+ ])
+ ->setThirdPartySetting('content_translation', 'enabled', TRUE)
+ ->save();
+
+ $this->contentDir = $this->getDrupalRoot() . '/core/tests/fixtures/default_content';
+ \Drupal::service('file_system')->copy($this->contentDir . '/file/druplicon_copy.png', $this->publicFilesDirectory . '/druplicon_copy.png', FileExists::Error);
+ }
+
+ /**
+ * @return array<array<mixed>>
+ */
+ public static function providerImportEntityThatAlreadyExists(): array {
+ return [
+ [Existing::Error],
+ [Existing::Skip],
+ ];
+ }
+
+ /**
+ * @dataProvider providerImportEntityThatAlreadyExists
+ */
+ public function testImportEntityThatAlreadyExists(Existing $existing): void {
+ $this->drupalCreateUser(values: ['uuid' => '94503467-be7f-406c-9795-fc25baa22203']);
+
+ if ($existing === Existing::Error) {
+ $this->expectException(ImportException::class);
+ $this->expectExceptionMessage('user 94503467-be7f-406c-9795-fc25baa22203 already exists.');
+ }
+
+ $this->container->get(Importer::class)
+ ->importContent(new Finder($this->contentDir), $existing);
+ }
+
+ /**
+ * Tests importing content directly, via the API.
+ */
+ public function testDirectContentImport(): void {
+ $logger = new TestLogger();
+
+ /** @var \Drupal\Core\DefaultContent\Importer $importer */
+ $importer = $this->container->get(Importer::class);
+ $importer->setLogger($logger);
+ $importer->importContent(new Finder($this->contentDir));
+
+ $this->assertContentWasImported();
+ // We should see a warning about importing a file entity associated with a
+ // file that doesn't exist.
+ $predicate = function (array $record): bool {
+ return (
+ $record['message'] === 'File entity %name was imported, but the associated file (@path) was not found.' &&
+ $record['context']['%name'] === 'dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' &&
+ $record['context']['@path'] === $this->contentDir . '/file/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png'
+ );
+ };
+ $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::WARNING));
+ }
+
+ /**
+ * Tests that the importer validates entities before saving them.
+ */
+ public function testEntityValidationIsTriggered(): void {
+ $dir = uniqid('public://');
+ mkdir($dir);
+
+ /** @var string $data */
+ $data = file_get_contents($this->contentDir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml');
+ $data = Yaml::decode($data);
+ /** @var array{default: array{sticky: array<int, array{value: mixed}>}} $data */
+ $data['default']['sticky'][0]['value'] = 'not a boolean!';
+ file_put_contents($dir . '/invalid.yml', Yaml::encode($data));
+
+ $this->expectException(InvalidEntityException::class);
+ $this->expectExceptionMessage("$dir/invalid.yml: sticky.0.value=This value should be of the correct primitive type.");
+ $this->container->get(Importer::class)->importContent(new Finder($dir));
+ }
+
+ /**
+ * Asserts that the default content was imported as expected.
+ */
+ private function assertContentWasImported(): void {
+ /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+ $entity_repository = $this->container->get(EntityRepositoryInterface::class);
+
+ $node = $entity_repository->loadEntityByUuid('node', 'e1714f23-70c0-4493-8e92-af1901771921');
+ $this->assertInstanceOf(NodeInterface::class, $node);
+ $this->assertSame('Crikey it works!', $node->body->value);
+ $this->assertSame('article', $node->bundle());
+ $this->assertSame('Test Article', $node->label());
+ $tag = $node->field_tags->entity;
+ $this->assertInstanceOf(TermInterface::class, $tag);
+ $this->assertSame('Default Content', $tag->label());
+ $this->assertSame('tags', $tag->bundle());
+ $this->assertSame('550f86ad-aa11-4047-953f-636d42889f85', $tag->uuid());
+ // The tag carries a field with serialized data, so ensure it came through
+ // properly.
+ $this->assertSame('a:2:{i:0;s:2:"Hi";i:1;s:6:"there!";}', $tag->field_serialized_stuff->value);
+ $this->assertSame('94503467-be7f-406c-9795-fc25baa22203', $node->getOwner()->uuid());
+ // The node's URL should use the path alias shipped with the recipe.
+ $node_url = $node->toUrl()->toString();
+ $this->assertSame(Url::fromUserInput('/test-article')->toString(), $node_url);
+
+ $media = $entity_repository->loadEntityByUuid('media', '344b943c-b231-4d73-9669-0b0a2be12aa5');
+ $this->assertInstanceOf(MediaInterface::class, $media);
+ $this->assertSame('image', $media->bundle());
+ $this->assertSame('druplicon.png', $media->label());
+ $file = $media->field_media_image->entity;
+ $this->assertInstanceOf(FileInterface::class, $file);
+ $this->assertSame('druplicon.png', $file->getFilename());
+ $this->assertSame('d8404562-efcc-40e3-869e-40132d53fe0b', $file->uuid());
+
+ // Another file entity referencing an existing file but already in use by
+ // another entity, should be imported.
+ $same_file_different_entity = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f');
+ $this->assertInstanceOf(FileInterface::class, $same_file_different_entity);
+ $this->assertSame('druplicon-duplicate.png', $same_file_different_entity->getFilename());
+ $this->assertStringEndsWith('/druplicon_0.png', (string) $same_file_different_entity->getFileUri());
+
+ // Another file entity that references a file with the same name as, but
+ // different contents than, an existing file, should be imported and the
+ // file should be renamed.
+ $different_file = $entity_repository->loadEntityByUuid('file', 'a6b79928-838f-44bd-a8f0-44c2fff9e4cc');
+ $this->assertInstanceOf(FileInterface::class, $different_file);
+ $this->assertSame('druplicon-different.png', $different_file->getFilename());
+ $this->assertStringEndsWith('/druplicon_1.png', (string) $different_file->getFileUri());
+
+ // Another file entity referencing an existing file but one that is not in
+ // use by another entity, should be imported but use the existing file.
+ $different_file = $entity_repository->loadEntityByUuid('file', '7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d');
+ $this->assertInstanceOf(FileInterface::class, $different_file);
+ $this->assertSame('druplicon_copy.png', $different_file->getFilename());
+ $this->assertStringEndsWith('/druplicon_copy.png', (string) $different_file->getFileUri());
+
+ // Our node should have a menu link, and it should use the path alias we
+ // included with the recipe.
+ $menu_link = $entity_repository->loadEntityByUuid('menu_link_content', '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b');
+ $this->assertInstanceOf(MenuLinkContentInterface::class, $menu_link);
+ $this->assertSame($menu_link->getUrlObject()->toString(), $node_url);
+ $this->assertSame('main', $menu_link->getMenuName());
+
+ $block_content = $entity_repository->loadEntityByUuid('block_content', 'd9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf');
+ $this->assertInstanceOf(BlockContentInterface::class, $block_content);
+ $this->assertSame('basic', $block_content->bundle());
+ $this->assertSame('Useful Info', $block_content->label());
+ $this->assertSame("I'd love to put some useful info here.", $block_content->body->value);
+
+ // A node with a non-existent owner should be reassigned to the current
+ // user.
+ $node = $entity_repository->loadEntityByUuid('node', '7f1dd75a-0be2-4d3b-be5d-9d1a868b9267');
+ $this->assertInstanceOf(NodeInterface::class, $node);
+ $this->assertSame(\Drupal::currentUser()->id(), $node->getOwner()->id());
+
+ // Ensure a node with a translation is imported properly.
+ $node = $entity_repository->loadEntityByUuid('node', '2d3581c3-92c7-4600-8991-a0d4b3741198');
+ $this->assertInstanceOf(NodeInterface::class, $node);
+ $translation = $node->getTranslation('fr');
+ $this->assertSame('Perdu en traduction', $translation->label());
+ $this->assertSame("Içi c'est la version français.", $translation->body->value);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
new file mode 100644
index 00000000000..ec34c1036b7
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
@@ -0,0 +1,324 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Config\Action;
+
+// cspell:ignore inflector
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Uuid\Uuid;
+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName;
+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\DuplicateConfigActionIdException;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests the config action system.
+ *
+ * @group config
+ */
+class ConfigActionTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['config_test'];
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate
+ */
+ public function testEntityCreate(): void {
+ $this->assertCount(0, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There are no config_test entities');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+ /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+ $this->assertCount(1, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There is 1 config_test entity');
+ $this->assertSame('Action test', $config_test_entities['action_test']->label());
+ $this->assertTrue(Uuid::isValid((string) $config_test_entities['action_test']->uuid()), 'Config entity assigned a valid UUID');
+
+ // Calling ensure exists action again will not error.
+ $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+
+ try {
+ $manager->applyAction('entity_create:create', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+ $this->fail('Expected exception not thrown');
+ }
+ catch (ConfigActionException $e) {
+ $this->assertSame('Entity config_test.dynamic.action_test exists', $e->getMessage());
+ }
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+ */
+ public function testEntityMethod(): void {
+ $this->installConfig('config_test');
+ $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Default', $config_test_entity->getProtectedProperty());
+
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ // Call a method action.
+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value 2');
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value 2', $config_test_entity->getProtectedProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', ['Test value ', '3']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value 3', $config_test_entity->getProtectedProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value ', '4']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value 4', $config_test_entity->getProtectedProperty());
+
+ // Test calling an action that has 2 arguments but one is optional with an
+ // array value.
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value 5']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value 5', $config_test_entity->getProtectedProperty());
+
+ // Test calling an action that has 2 arguments but one is optional with a
+ // non array value.
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', 'Test value 6');
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Test value 6', $config_test_entity->getProtectedProperty());
+
+ // Test calling an action that expects no arguments.
+ $manager->applyAction('entity_method:config_test.dynamic:defaultProtectedProperty', 'config_test.dynamic.dotted.default', []);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('Set by method', $config_test_entity->getProtectedProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'foo');
+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'bar');
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame(['foo', 'bar'], $config_test_entity->getArrayProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame(['foo', 'bar', ['a', 'b', 'c']], $config_test_entity->getArrayProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame(['a', 'b', 'c'], $config_test_entity->getArrayProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', [['a', 'b', 'c'], ['a']]);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame([['a', 'b', 'c'], ['a']], $config_test_entity->getArrayProperty());
+
+ $config_test_entity->delete();
+ try {
+ $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+ $this->fail('Expected exception not thrown');
+ }
+ catch (ConfigActionException $e) {
+ $this->assertSame('Entity config_test.dynamic.dotted.default does not exist', $e->getMessage());
+ }
+
+ // Test custom and default admin labels.
+ $this->assertSame('Test configuration append', (string) $manager->getDefinition('entity_method:config_test.dynamic:append')['admin_label']);
+ $this->assertSame('Set default name', (string) $manager->getDefinition('entity_method:config_test.dynamic:defaultProtectedProperty')['admin_label']);
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+ */
+ public function testPluralizedEntityMethod(): void {
+ $this->installConfig('config_test');
+ $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ // Call a pluralized method action.
+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', ['a', 'b', 'c', 'd']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame(['a', 'b', 'c', 'd'], $config_test_entity->getArrayProperty());
+
+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', [['foo'], 'bar']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame(['a', 'b', 'c', 'd', ['foo'], 'bar'], $config_test_entity->getArrayProperty());
+
+ $config_test_entity->setProtectedProperty('')->save();
+ $manager->applyAction('entity_method:config_test.dynamic:appends', 'config_test.dynamic.dotted.default', ['1', '2', '3']);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('123', $config_test_entity->getProtectedProperty());
+
+ // Test that the inflector converts to a good plural form.
+ $config_test_entity->setProtectedProperty('')->save();
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperties', 'config_test.dynamic.dotted.default', [['1', '2'], ['3', '4']]);
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('dotted.default');
+ $this->assertSame('34', $config_test_entity->getProtectedProperty());
+
+ $this->assertTrue($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedProperty'), 'The setProtectedProperty action exists');
+ // cspell:ignore Propertys
+ $this->assertFalse($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedPropertys'), 'There is no automatically pluralized version of the setProtectedProperty action');
+
+ // Admin label for pluralized form.
+ $this->assertSame('Test configuration append (multiple calls)', (string) $manager->getDefinition('entity_method:config_test.dynamic:appends')['admin_label']);
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+ */
+ public function testPluralizedEntityMethodException(): void {
+ $this->installConfig('config_test');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $this->expectException(EntityMethodException::class);
+ $this->expectExceptionMessage('The pluralized entity method config action \'entity_method:config_test.dynamic:addToArrayMultipleTimes\' requires an array value in order to call Drupal\config_test\Entity\ConfigTest::addToArray() multiple times');
+ $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', 'Test value');
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
+ */
+ public function testDuplicatePluralizedMethodNameException(): void {
+ \Drupal::state()->set('config_test.class_override', DuplicatePluralizedMethodName::class);
+ \Drupal::entityTypeManager()->clearCachedDefinitions();
+ $this->installConfig('config_test');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $this->expectException(EntityMethodException::class);
+ $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName::testMethod(). The existing action is for the ::testMethod() method');
+ $manager->getDefinitions();
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
+ */
+ public function testDuplicatePluralizedOtherMethodNameException(): void {
+ \Drupal::state()->set('config_test.class_override', DuplicatePluralizedOtherMethodName::class);
+ \Drupal::entityTypeManager()->clearCachedDefinitions();
+ $this->installConfig('config_test');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $this->expectException(EntityMethodException::class);
+ $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod2\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName::testMethod2(). The existing action is for the ::testMethod() method');
+ $manager->getDefinitions();
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+ */
+ public function testEntityMethodException(): void {
+ $this->installConfig('config_test');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $this->expectException(EntityMethodException::class);
+ $this->expectExceptionMessage('Entity method config action \'entity_method:config_test.dynamic:concatProtectedProperty\' requires an array value. The number of parameters or required parameters for Drupal\config_test\Entity\ConfigTest::concatProtectedProperty() is not 1');
+ $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate
+ */
+ public function testSimpleConfigUpdate(): void {
+ $this->installConfig('config_test');
+ $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
+
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ // Call the simple config update action.
+ $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
+ $this->assertSame('Yay!', $this->config('config_test.system')->get('foo'));
+
+ try {
+ $manager->applyAction('simple_config_update', 'config_test.system', 'Test');
+ $this->fail('Expected exception not thrown');
+ }
+ catch (ConfigActionException $e) {
+ $this->assertSame('Config config_test.system can not be updated because $value is not an array', $e->getMessage());
+ }
+
+ $this->config('config_test.system')->delete();
+ try {
+ $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
+ $this->fail('Expected exception not thrown');
+ }
+ catch (ConfigActionException $e) {
+ $this->assertSame('Config config_test.system does not exist so can not be updated', $e->getMessage());
+ }
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+ */
+ public function testShorthandActionIds(): void {
+ $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+ $this->assertCount(0, $storage->loadMultiple(), 'There are no config_test entities');
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
+ /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
+ $config_test_entities = $storage->loadMultiple();
+ $this->assertCount(1, $config_test_entities, 'There is 1 config_test entity');
+ $this->assertSame('Action test', $config_test_entities['action_test']->label());
+
+ $this->assertSame('', $config_test_entities['action_test']->getProtectedProperty());
+
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ // Call a method action.
+ $manager->applyAction('setProtectedProperty', 'config_test.dynamic.action_test', 'Test value');
+ /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+ $config_test_entity = $storage->load('action_test');
+ $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+ */
+ public function testDuplicateShorthandActionIds(): void {
+ $this->enableModules(['config_action_duplicate_test']);
+ /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+ $manager = $this->container->get('plugin.manager.config_action');
+ $this->expectException(DuplicateConfigActionIdException::class);
+ $this->expectExceptionMessage("The plugins 'entity_method:config_test.dynamic:setProtectedProperty' and 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty' both resolve to the same shorthand action ID for the 'config_test' entity type");
+ $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+ */
+ public function testParentAttributes(): void {
+ $definitions = $this->container->get('plugin.manager.config_action')->getDefinitions();
+ // The \Drupal\config_test\Entity\ConfigQueryTest::concatProtectedProperty()
+ // does not have an attribute but the parent does so this is discovered.
+ $this->assertArrayHasKey('entity_method:config_test.query:concatProtectedProperty', $definitions);
+ }
+
+ /**
+ * @see \Drupal\Core\Config\Action\ConfigActionManager
+ */
+ public function testMissingAction(): void {
+ $this->expectException(PluginNotFoundException::class);
+ $this->expectExceptionMessageMatches('/^The "does_not_exist" plugin does not exist/');
+ $this->container->get('plugin.manager.config_action')->applyAction('does_not_exist', 'config_test.system', ['foo' => 'Yay!']);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php
new file mode 100644
index 00000000000..9032d234bce
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php
@@ -0,0 +1,310 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Config\Storage\Checkpoint;
+
+use Drupal\Core\Config\Checkpoint\CheckpointStorageInterface;
+use Drupal\Core\Config\ConfigImporter;
+use Drupal\Core\Config\StorageComparer;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests CheckpointStorage operations.
+ *
+ * @group config
+ */
+class CheckpointStorageTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['system', 'config_test'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installConfig(['system', 'config_test']);
+ }
+
+ public function testConfigSaveAndRead(): void {
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+ $this->config('system.site')->set('name', 'Test1')->save();
+ $check1 = $checkpoint_storage->checkpoint('A');
+ $this->config('system.site')->set('name', 'Test2')->save();
+ $check2 = $checkpoint_storage->checkpoint('B');
+ $this->config('system.site')->set('name', 'Test3')->save();
+
+ $this->assertSame('Test3', $this->config('system.site')->get('name'));
+ $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']);
+
+ // The config listings should be exactly the same.
+ $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+ $this->assertSame('Test2', $checkpoint_storage->read('system.site')['name']);
+ $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+
+ $checkpoint_storage->setCheckpointToReadFrom($check1);
+ $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']);
+ $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+ }
+
+ public function testConfigDelete(): void {
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+ $check1 = $checkpoint_storage->checkpoint('A');
+ $this->config('config_test.system')->delete();
+
+ $this->assertFalse($this->container->get('config.storage')->exists('config_test.system'));
+ $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+ $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+
+ $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+ $this->assertContains('config_test.system', $checkpoint_storage->listAll('config_test.'));
+ $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('system.'));
+ // Should not be part of the active storage anymore.
+ $this->assertNotContains('config_test.system', $this->container->get('config.storage')->listAll());
+
+ $check2 = $checkpoint_storage->checkpoint('B');
+
+ $this->config('config_test.system')->set('foo', 'foobar')->save();
+ $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+ $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+ $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+ $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->read('config_test.system'));
+ $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+
+ $checkpoint_storage->setCheckpointToReadFrom($check1);
+ $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+ $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+ $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+ }
+
+ public function testConfigCreate(): void {
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+ $this->config('config_test.system')->delete();
+ $check1 = $checkpoint_storage->checkpoint('A');
+ $this->config('config_test.system')->set('foo', 'foobar')->save();
+
+ $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+ $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+ $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('config_test.'));
+ $this->assertContains('system.site', $checkpoint_storage->listAll('system.'));
+ $this->assertContains('config_test.system', $this->container->get('config.storage')->listAll());
+
+ $check2 = $checkpoint_storage->checkpoint('B');
+ $this->config('config_test.system')->delete();
+
+ $this->assertFalse($this->container->get('config.storage')->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+ $this->config('config_test.system')->set('foo', 'foobar')->save();
+ $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+ $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+ $this->assertSame('foobar', $checkpoint_storage->read('config_test.system')['foo']);
+ $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+
+ $checkpoint_storage->setCheckpointToReadFrom($check1);
+ $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+ $this->assertFalse($checkpoint_storage->read('config_test.system'));
+ $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+ }
+
+ public function testConfigRename(): void {
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ $check1 = $checkpoint_storage->checkpoint('A');
+ $this->container->get('config.factory')->rename('config_test.dynamic.dotted.default', 'config_test.dynamic.renamed');
+ $this->config('config_test.dynamic.renamed')->set('id', 'renamed')->save();
+
+ $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.renamed'));
+ $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+ $this->assertSame('dotted.default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['id']);
+ $this->assertSame($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid'));
+
+ $check2 = $checkpoint_storage->checkpoint('B');
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityStorage $storage */
+ $storage = $this->container->get('entity_type.manager')->getStorage('config_test');
+ // Entity1 will be deleted by the test.
+ $entity1 = $storage->create(
+ [
+ 'id' => 'dotted.default',
+ 'label' => 'Another one',
+ ]
+ );
+ $entity1->save();
+
+ $check3 = $checkpoint_storage->checkpoint('C');
+
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+ $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+
+ $checkpoint_storage->setCheckpointToReadFrom($check3);
+ $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+ $this->assertNotEquals($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid'));
+ $this->assertSame('Another one', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']);
+
+ $checkpoint_storage->setCheckpointToReadFrom($check1);
+ $this->assertSame('Default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']);
+ }
+
+ public function testRevert(): void {
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ $check1 = $checkpoint_storage->checkpoint('A');
+ $this->assertTrue($this->container->get('module_installer')->uninstall(['config_test']));
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ $check2 = $checkpoint_storage->checkpoint('B');
+
+ $importer = $this->getConfigImporter($checkpoint_storage);
+ $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist();
+ $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['create']);
+ $this->assertSame(['core.extension'], $config_changelist['update']);
+ $this->assertSame([], $config_changelist['delete']);
+ $this->assertSame([], $config_changelist['rename']);
+
+ $importer->import();
+ $this->assertSame([], $importer->getErrors());
+
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'));
+
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+
+ $importer = $this->getConfigImporter($checkpoint_storage);
+ $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist();
+ $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['delete']);
+ $this->assertSame(['core.extension'], $config_changelist['update']);
+ $this->assertSame([], $config_changelist['create']);
+ $this->assertSame([], $config_changelist['rename']);
+ $importer->import();
+ $this->assertFalse($this->container->get('module_handler')->moduleExists('config_test'));
+
+ $checkpoint_storage->setCheckpointToReadFrom($check1);
+ $importer = $this->getConfigImporter($checkpoint_storage);
+ $importer->getStorageComparer()->createChangelist();
+ $importer->import();
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'));
+ }
+
+ public function testRevertWithCollections(): void {
+ $collections = [
+ 'another_collection',
+ 'collection.test1',
+ 'collection.test2',
+ ];
+ // Set the event listener to return three possible collections.
+ // @see \Drupal\config_collection_install_test\EventSubscriber
+ \Drupal::state()->set('config_collection_install_test.collection_names', $collections);
+
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ $checkpoint_storage->checkpoint('A');
+
+ // Install the test module.
+ $this->assertTrue($this->container->get('module_installer')->install(['config_collection_install_test']));
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+ /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+ $active_storage = \Drupal::service('config.storage');
+ $this->assertEquals($collections, $active_storage->getAllCollectionNames());
+ foreach ($collections as $collection) {
+ $collection_storage = $active_storage->createCollection($collection);
+ $data = $collection_storage->read('config_collection_install_test.test');
+ $this->assertEquals($collection, $data['collection']);
+ }
+
+ $check2 = $checkpoint_storage->checkpoint('B');
+
+ $importer = $this->getConfigImporter($checkpoint_storage);
+ $storage_comparer = $importer->getStorageComparer();
+ $config_changelist = $storage_comparer->createChangelist()->getChangelist();
+ $this->assertSame([], $config_changelist['create']);
+ $this->assertSame(['core.extension'], $config_changelist['update']);
+ $this->assertSame([], $config_changelist['delete']);
+ $this->assertSame([], $config_changelist['rename']);
+ foreach ($collections as $collection) {
+ $config_changelist = $storage_comparer->getChangelist(NULL, $collection);
+ $this->assertSame([], $config_changelist['create']);
+ $this->assertSame([], $config_changelist['update']);
+ $this->assertSame(['config_collection_install_test.test'], $config_changelist['delete'], $collection);
+ $this->assertSame([], $config_changelist['rename']);
+ }
+
+ $importer->import();
+ $this->assertSame([], $importer->getErrors());
+
+ $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+ /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+ $active_storage = \Drupal::service('config.storage');
+ $this->assertEmpty($active_storage->getAllCollectionNames());
+ foreach ($collections as $collection) {
+ $collection_storage = $active_storage->createCollection($collection);
+ $this->assertFalse($collection_storage->read('config_collection_install_test.test'));
+ }
+
+ $checkpoint_storage->setCheckpointToReadFrom($check2);
+
+ $importer = $this->getConfigImporter($checkpoint_storage);
+
+ $storage_comparer = $importer->getStorageComparer();
+ $config_changelist = $storage_comparer->createChangelist()->getChangelist();
+ $this->assertSame([], $config_changelist['create']);
+ $this->assertSame(['core.extension'], $config_changelist['update']);
+ $this->assertSame([], $config_changelist['delete']);
+ $this->assertSame([], $config_changelist['rename']);
+ foreach ($collections as $collection) {
+ $config_changelist = $storage_comparer->getChangelist(NULL, $collection);
+ $this->assertSame(['config_collection_install_test.test'], $config_changelist['create']);
+ $this->assertSame([], $config_changelist['update']);
+ $this->assertSame([], $config_changelist['delete'], $collection);
+ $this->assertSame([], $config_changelist['rename']);
+ }
+ $importer->import();
+ $this->assertSame([], $importer->getErrors());
+
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('config_collection_install_test'));
+ /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+ $active_storage = \Drupal::service('config.storage');
+ $this->assertEquals($collections, $active_storage->getAllCollectionNames());
+ foreach ($collections as $collection) {
+ $collection_storage = $active_storage->createCollection($collection);
+ $data = $collection_storage->read('config_collection_install_test.test');
+ $this->assertEquals($collection, $data['collection']);
+ }
+ }
+
+ private function getConfigImporter(CheckpointStorageInterface $storage): ConfigImporter {
+ $storage_comparer = new StorageComparer(
+ $storage,
+ $this->container->get('config.storage')
+ );
+ return new ConfigImporter(
+ $storage_comparer,
+ $this->container->get('event_dispatcher'),
+ $this->container->get('config.manager'),
+ $this->container->get('lock'),
+ $this->container->get('config.typed'),
+ $this->container->get('module_handler'),
+ $this->container->get('module_installer'),
+ $this->container->get('theme_handler'),
+ $this->container->get('string_translation'),
+ $this->container->get('extension.list.module'),
+ $this->container->get('extension.list.theme')
+ );
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php
new file mode 100644
index 00000000000..700fc7d4379
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\DefaultContent;
+
+use Drupal\Core\Access\AccessException;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\DefaultContent\AdminAccountSwitcher;
+use Drupal\Core\Session\AccountSwitcherInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\AdminAccountSwitcher
+ * @group DefaultContent
+ */
+class AdminAccountSwitcherTest extends KernelTestBase {
+
+ use UserCreationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['system', 'user'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installEntitySchema('user');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function register(ContainerBuilder $container): void {
+ parent::register($container);
+ $container->getDefinition(AdminAccountSwitcher::class)->setPublic(TRUE);
+ }
+
+ /**
+ * Tests switching to a user with an administrative role.
+ */
+ public function testSwitchToAdministrator(): void {
+ /** @var \Drupal\Core\Session\AccountInterface $account */
+ $account = $this->createUser(admin: TRUE);
+
+ $this->assertSame($account->id(), $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator()->id());
+ $this->assertSame($account->id(), $this->container->get('current_user')->id());
+ }
+
+ /**
+ * Tests that there is an error if there are no administrative users.
+ */
+ public function testNoAdministratorsExist(): void {
+ /** @var \Drupal\Core\Session\AccountInterface $account */
+ $account = $this->createUser();
+ $this->assertSame(1, (int) $account->id());
+
+ $this->expectException(AccessException::class);
+ $this->expectExceptionMessage("There are no user accounts with administrative roles.");
+ $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator();
+ }
+
+ /**
+ * Tests switching to user 1 when the superuser access policy is enabled.
+ */
+ public function testSuperUser(): void {
+ /** @var \Drupal\Core\Session\AccountInterface $account */
+ $account = $this->createUser();
+ $this->assertSame(1, (int) $account->id());
+
+ $switcher = new AdminAccountSwitcher(
+ $this->container->get(AccountSwitcherInterface::class),
+ $this->container->get(EntityTypeManagerInterface::class),
+ TRUE,
+ );
+ $this->assertSame(1, (int) $switcher->switchToAdministrator()->id());
+ }
+
+ public function testSwitchToAndSwitchBack(): void {
+ $this->assertTrue($this->container->get('current_user')->isAnonymous());
+
+ /** @var \Drupal\Core\Session\AccountInterface $account */
+ $account = $this->createUser();
+ $switcher = $this->container->get(AdminAccountSwitcher::class);
+ $this->assertSame($switcher, $switcher->switchTo($account));
+ $this->assertSame($account->id(), $this->container->get('current_user')->id());
+
+ $this->assertSame($switcher, $switcher->switchBack());
+ $this->assertTrue($this->container->get('current_user')->isAnonymous());
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php
new file mode 100644
index 00000000000..eef3ba5f8a6
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class ConfigActionValidationTest extends KernelTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'block_content',
+ 'link',
+ 'node',
+ 'shortcut',
+ 'system',
+ ];
+
+ /**
+ * {@inheritdoc}
+ *
+ * This test requires that we save invalid config, so we can test that it gets
+ * validated after applying a recipe.
+ */
+ protected $strictConfigSchema = FALSE;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installConfig('shortcut');
+ $this->installEntitySchema('shortcut');
+ }
+
+ /**
+ * @testWith ["block_content_type"]
+ * ["node_type"]
+ * ["shortcut_set"]
+ * ["menu"]
+ */
+ public function testConfigActionsAreValidated(string $entity_type_id): void {
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $storage */
+ $storage = $this->container->get(EntityTypeManagerInterface::class)
+ ->getStorage($entity_type_id);
+
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+ $entity_type = $storage->getEntityType();
+ // If there is a label key, it's safe to assume that it's not allowed to be
+ // empty. We don't care whether it's immutable; we just care that the value
+ // the config action sets it to (an empty string) violates config schema.
+ $label_key = $entity_type->getKey('label');
+ $this->assertNotEmpty($label_key);
+ $entity = $storage->create([
+ $entity_type->getKey('id') => 'test',
+ $label_key => 'Test',
+ ]);
+ $entity->save();
+
+ $config_name = $entity->getConfigDependencyName();
+ $recipe_data = <<<YAML
+name: Config actions making bad decisions
+config:
+ actions:
+ $config_name:
+ simple_config_update:
+ $label_key: ''
+YAML;
+
+ $recipe = $this->createRecipe($recipe_data);
+ try {
+ RecipeRunner::processRecipe($recipe);
+ $this->fail('An exception should have been thrown.');
+ }
+ catch (InvalidConfigException $e) {
+ $this->assertCount(1, $e->violations);
+ $violation = $e->violations->get(0);
+ $this->assertSame($label_key, $violation->getPropertyPath());
+ $this->assertSame("This value should not be blank.", (string) $violation->getMessage());
+ }
+ }
+
+ /**
+ * Tests validating that config actions' dependencies are present.
+ *
+ * Tests that the all of the config listed in a recipe's config actions are
+ * provided by extensions that will be installed by the recipe, or one of its
+ * dependencies (no matter how deeply nested).
+ *
+ * @testWith ["direct_dependency"]
+ * ["indirect_dependency_one_level_down"]
+ * ["indirect_dependency_two_levels_down"]
+ */
+ public function testConfigActionDependenciesAreValidated(string $name): void {
+ Recipe::createFromDirectory("core/tests/fixtures/recipes/config_actions_dependency_validation/$name");
+ }
+
+ /**
+ * Tests config action validation for missing dependency.
+ */
+ public function testConfigActionMissingDependency(): void {
+ $recipe_data = <<<YAML
+name: Config actions making bad decisions
+config:
+ actions:
+ random.config:
+ simple_config_update:
+ label: ''
+YAML;
+
+ try {
+ $this->createRecipe($recipe_data);
+ $this->fail('An exception should have been thrown.');
+ }
+ catch (RecipeFileException $e) {
+ $this->assertIsObject($e->violations);
+ $this->assertCount(1, $e->violations);
+ $this->assertSame('[config][actions][random.config]', $e->violations[0]->getPropertyPath());
+ $this->assertSame("Config actions cannot be applied to random.config because the random extension is not installed, and is not installed by this recipe or any of the recipes it depends on.", (string) $e->violations[0]->getMessage());
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php
new file mode 100644
index 00000000000..b91ea75b37b
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\ConfigConfigurator
+ * @group Recipe
+ */
+class ConfigConfiguratorTest extends KernelTestBase {
+
+ public function testExistingConfigWithKeysInDifferentOrder(): void {
+ $recipe_dir = uniqid('public://recipe_test_');
+ mkdir($recipe_dir . '/config', recursive: TRUE);
+
+ $this->enableModules(['system']);
+ $this->installConfig('system');
+ /** @var mixed[][] $original_data */
+ $original_data = $this->config('system.site')->get();
+ // Remove keys that are ignored during the comparison.
+ unset($original_data['uuid'], $original_data['_core']);
+ $recipe_data = $original_data;
+ // Reorder an inner array, to ensure keys are sorted recursively.
+ $recipe_data['page'] = array_reverse($original_data['page'], TRUE);
+ $this->assertNotSame($original_data, $recipe_data);
+ file_put_contents($recipe_dir . '/config/system.site.yml', Yaml::encode($recipe_data));
+
+ $recipe = [
+ 'name' => 'Same config, different order',
+ 'type' => 'Testing',
+ ];
+ file_put_contents($recipe_dir . '/recipe.yml', Yaml::encode($recipe));
+
+ // If there was a conflict with the pre-existing config, ConfigConfigurator
+ // would throw an exception and the recipe would not be created. So all we
+ // need to do here is assert that, in fact, we were able to create a recipe
+ // object.
+ $this->assertInstanceOf(Recipe::class, Recipe::createFromDirectory($recipe_dir));
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php
new file mode 100644
index 00000000000..0d6119703c9
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class ConfigValidationTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ *
+ * This test depends on us being able to create invalid config, so we can
+ * ensure that validatable config is validated by the recipe runner.
+ */
+ protected $strictConfigSchema = FALSE;
+
+ /**
+ * Creates a recipe with invalid config data in a particular file.
+ *
+ * @param string $file
+ * The name of the file (in the recipe's `config` directory) which should
+ * have invalid data.
+ *
+ * @return \Drupal\Core\Recipe\Recipe
+ * A wrapper around the created recipe.
+ */
+ private function createRecipeWithInvalidDataInFile(string $file): Recipe {
+ $dir = uniqid('public://');
+ mkdir($dir . '/config', recursive: TRUE);
+
+ $data = file_get_contents($this->getDrupalRoot() . '/core/modules/config/tests/config_test/config/install/config_test.types.yml');
+ assert(is_string($data));
+ $data = Yaml::decode($data);
+ // The `array` key needs to be an array, not an integer. If the config is
+ // validated, this will raise a validation error.
+ /** @var mixed[] $data */
+ $data['array'] = 39;
+ file_put_contents($dir . '/config/' . $file, Yaml::encode($data));
+
+ $recipe = <<<YAML
+name: Config validation test
+install:
+ - config_test
+YAML;
+ file_put_contents($dir . '/recipe.yml', $recipe);
+
+ return Recipe::createFromDirectory($dir);
+ }
+
+ /**
+ * Tests that the recipe runner only validates config which is validatable.
+ */
+ public function testValidatableConfigIsValidated(): void {
+ // Since config_test.types is not validatable, there should not be a
+ // validation error.
+ $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.yml');
+ RecipeRunner::processRecipe($recipe);
+ $this->assertFalse($this->config('config_test.types')->isNew());
+
+ // If we create a config object which IS fully validatable, and has invalid
+ // data, we should get a validation error.
+ $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.fully_validatable.yml');
+ $this->expectException(InvalidConfigException::class);
+ $this->expectExceptionMessage('There were validation errors in config_test.types.fully_validatable');
+ RecipeRunner::processRecipe($recipe);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php
new file mode 100644
index 00000000000..5590fe0e75a
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * @group Recipe
+ */
+class EntityMethodConfigActionsTest extends KernelTestBase {
+
+ use ContentTypeCreationTrait;
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'field',
+ 'layout_builder',
+ 'layout_discovery',
+ 'node',
+ 'system',
+ 'text',
+ 'user',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->installConfig('node');
+ $this->createContentType(['type' => 'test']);
+
+ $this->container->get(EntityDisplayRepositoryInterface::class)
+ ->getViewDisplay('node', 'test', 'full')
+ ->save();
+ }
+
+ public function testSetSingleThirdPartySetting(): void {
+ $recipe = <<<YAML
+name: Third-party setting
+config:
+ actions:
+ core.entity_view_display.node.test.full:
+ setThirdPartySetting:
+ module: layout_builder
+ key: enabled
+ value: true
+YAML;
+ $recipe = $this->createRecipe($recipe);
+ RecipeRunner::processRecipe($recipe);
+
+ /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */
+ $display = $this->container->get(EntityDisplayRepositoryInterface::class)
+ ->getViewDisplay('node', 'test', 'full');
+ $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled'));
+ }
+
+ public function testSetMultipleThirdPartySettings(): void {
+ $recipe = <<<YAML
+name: Third-party setting
+config:
+ actions:
+ core.entity_view_display.node.test.full:
+ setThirdPartySettings:
+ -
+ module: layout_builder
+ key: enabled
+ value: true
+ -
+ module: layout_builder
+ key: allow_custom
+ value: true
+YAML;
+ $recipe = $this->createRecipe($recipe);
+ RecipeRunner::processRecipe($recipe);
+
+ /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */
+ $display = $this->container->get(EntityDisplayRepositoryInterface::class)
+ ->getViewDisplay('node', 'test', 'full');
+ $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled'));
+ $this->assertTrue($display->getThirdPartySetting('layout_builder', 'allow_custom'));
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php
new file mode 100644
index 00000000000..fa9f9f3c31d
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\Recipe\InstallConfigurator;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\InstallConfigurator
+ * @group Recipe
+ */
+class InstallConfiguratorTest extends KernelTestBase {
+
+ public function testDependenciesAreAutomaticallyIncluded(): void {
+ $configurator = new InstallConfigurator(
+ ['node', 'test_theme_depending_on_modules'],
+ $this->container->get(ModuleExtensionList::class),
+ $this->container->get(ThemeExtensionList::class),
+ );
+
+ // Node and its dependencies should be listed.
+ $this->assertContains('node', $configurator->modules);
+ $this->assertContains('text', $configurator->modules);
+ $this->assertContains('field', $configurator->modules);
+ $this->assertContains('filter', $configurator->modules);
+ // The test theme, along with its module AND theme dependencies, should be
+ // listed.
+ $this->assertContains('test_theme_depending_on_modules', $configurator->themes);
+ $this->assertContains('test_module_required_by_theme', $configurator->modules);
+ $this->assertContains('test_another_module_required_by_theme', $configurator->modules);
+ $this->assertContains('stark', $configurator->themes);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php
new file mode 100644
index 00000000000..c9465a31d49
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php
@@ -0,0 +1,231 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\PermissionsPerBundle
+ * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver
+ *
+ * @group Recipe
+ */
+class PermissionsPerBundleTest extends KernelTestBase {
+
+ use ContentTypeCreationTrait;
+ use MediaTypeCreationTrait;
+ use RecipeTestTrait;
+ use TaxonomyTestTrait;
+ use UserCreationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'field',
+ 'media',
+ 'media_test_source',
+ 'node',
+ 'system',
+ 'taxonomy',
+ 'text',
+ 'user',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installConfig('node');
+
+ $this->createRole([], 'super_editor');
+
+ $this->createContentType(['type' => 'article']);
+ $this->createContentType(['type' => 'blog']);
+ $this->createContentType(['type' => 'landing_page']);
+
+ $this->createMediaType('test', ['id' => 'beautiful']);
+ $this->createMediaType('test', ['id' => 'controversial']);
+ $this->createMediaType('test', ['id' => 'special']);
+
+ $this->createVocabulary(['vid' => 'tags']);
+ $this->createVocabulary(['vid' => 'categories']);
+ }
+
+ /**
+ * Tests granting multiple bundle-specific permissions.
+ */
+ public function testGrantPermissionsPerBundle(): void {
+ $recipe_data = <<<YAML
+name: 'Multi permissions!'
+config:
+ actions:
+ user.role.super_editor:
+ grantPermissionsForEachNodeType:
+ - create %bundle content
+ - edit own %bundle content
+ grantPermissionsForEachMediaType:
+ permissions:
+ - create %bundle media
+ - edit own %bundle media
+ grantPermissionsForEachTaxonomyVocabulary: create terms in %bundle
+YAML;
+ $this->applyRecipeFromString($recipe_data);
+
+ $expected_permissions = [
+ 'create article content',
+ 'create blog content',
+ 'create landing_page content',
+ 'edit own article content',
+ 'edit own blog content',
+ 'edit own landing_page content',
+ 'create beautiful media',
+ 'create controversial media',
+ 'create special media',
+ 'edit own beautiful media',
+ 'edit own controversial media',
+ 'edit own special media',
+ 'create terms in tags',
+ 'create terms in categories',
+ ];
+ $role = Role::load('super_editor');
+ assert($role instanceof RoleInterface);
+ foreach ($expected_permissions as $permission) {
+ $this->assertTrue($role->hasPermission($permission));
+ }
+ }
+
+ /**
+ * Tests that the permissions-per-bundle action can only be applied to roles.
+ */
+ public function testActionIsOnlyAvailableToUserRoles(): void {
+ $recipe_data = <<<YAML
+name: 'Only for roles...'
+config:
+ actions:
+ field.storage.node.body:
+ grantPermissionsForEachNodeType:
+ - create %bundle content
+ - edit own %bundle content
+YAML;
+
+ $this->expectException(PluginNotFoundException::class);
+ $this->expectExceptionMessage('The "grantPermissionsForEachNodeType" plugin does not exist.');
+ $this->applyRecipeFromString($recipe_data);
+ }
+
+ /**
+ * Tests granting permissions for one bundle, then all of them.
+ */
+ public function testGrantPermissionsOnOneBundleThenAll(): void {
+ $recipe_data = <<<YAML
+name: 'All bundles except one'
+config:
+ actions:
+ user.role.super_editor:
+ grantPermissions:
+ - create beautiful media
+ - edit own beautiful media
+ grantPermissionsForEachMediaType:
+ - create %bundle media
+ - edit own %bundle media
+YAML;
+ $this->applyRecipeFromString($recipe_data);
+
+ $role = Role::load('super_editor');
+ $this->assertInstanceOf(Role::class, $role);
+ $this->assertTrue($role->hasPermission('create beautiful media'));
+ $this->assertTrue($role->hasPermission('edit own beautiful media'));
+ $this->assertTrue($role->hasPermission('create controversial media'));
+ $this->assertTrue($role->hasPermission('edit own beautiful media'));
+ }
+
+ /**
+ * Tests granting permissions for all bundles except certain ones.
+ */
+ public function testGrantPermissionsToAllBundlesExceptSome(): void {
+ $recipe_data = <<<YAML
+name: 'Bundle specific permissions with some exceptions'
+config:
+ actions:
+ user.role.super_editor:
+ grantPermissionsForEachNodeType:
+ permissions:
+ - view %bundle revisions
+ except:
+ - article
+ - blog
+ grantPermissionsForEachMediaType:
+ permissions: view any %bundle media revisions
+ except:
+ - controversial
+ grantPermissionsForEachTaxonomyVocabulary:
+ permissions:
+ - view term revisions in %bundle
+ except: tags
+YAML;
+ $this->applyRecipeFromString($recipe_data);
+
+ $role = Role::load('super_editor');
+ $this->assertInstanceOf(Role::class, $role);
+ $this->assertTrue($role->hasPermission('view landing_page revisions'));
+ $this->assertFalse($role->hasPermission('view article revisions'));
+ $this->assertFalse($role->hasPermission('view blog revisions'));
+ $this->assertTrue($role->hasPermission('view any beautiful media revisions'));
+ $this->assertTrue($role->hasPermission('view any special media revisions'));
+ $this->assertFalse($role->hasPermission('view any controversial media revisions'));
+ $this->assertTrue($role->hasPermission('view term revisions in categories'));
+ $this->assertFalse($role->hasPermission('view term revisions in tags'));
+ }
+
+ /**
+ * Tests that there is an exception if the permission templates are invalid.
+ *
+ * @param mixed $value
+ * The permission template which should raise an error.
+ *
+ * @testWith [["a %Bundle permission"]]
+ * [""]
+ * [[]]
+ */
+ public function testInvalidValue(mixed $value): void {
+ $value = Json::encode($value);
+
+ $recipe_data = <<<YAML
+name: 'Bad permission value'
+config:
+ actions:
+ user.role.super_editor:
+ grantPermissionsForEachMediaType: $value
+YAML;
+ $this->expectException(ConfigActionException::class);
+ $this->expectExceptionMessage(" must be an array of strings that contain '%bundle'.");
+ $this->applyRecipeFromString($recipe_data);
+ }
+
+ /**
+ * Given a string of `recipe.yml` contents, applies it to the site.
+ *
+ * @param string $recipe_data
+ * The contents of `recipe.yml`.
+ */
+ private function applyRecipeFromString(string $recipe_data): void {
+ $recipe = $this->createRecipe($recipe_data);
+ RecipeRunner::processRecipe($recipe);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php
new file mode 100644
index 00000000000..640cc182de8
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeConfigurator;
+use Drupal\Core\Recipe\RecipeDiscovery;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\RecipeConfigurator
+ * @group Recipe
+ */
+class RecipeConfiguratorTest extends KernelTestBase {
+
+ public function testRecipeConfigurator(): void {
+ $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+ $recipe_configurator = new RecipeConfigurator(
+ ['install_two_modules', 'install_node_with_config', 'recipe_include'],
+ $discovery
+ );
+ // Private method "listAllRecipes".
+ $reflection = new \ReflectionMethod('\Drupal\Core\Recipe\RecipeConfigurator', 'listAllRecipes');
+
+ // Test methods.
+ /** @var \Drupal\Core\Recipe\Recipe[] $recipes */
+ $recipes = (array) $reflection->invoke($recipe_configurator);
+ $recipes_names = array_map(fn(Recipe $recipe) => $recipe->name, $recipes);
+ $recipe_extensions = $recipe_configurator->listAllExtensions();
+ $expected_recipes_names = [
+ 'Install two modules',
+ 'Install node with config',
+ 'Recipe include',
+ ];
+ $expected_recipe_extensions = [
+ 'system',
+ 'user',
+ 'filter',
+ 'field',
+ 'text',
+ 'node',
+ 'dblog',
+ ];
+
+ $this->assertEquals($expected_recipes_names, $recipes_names);
+ $this->assertEquals($expected_recipe_extensions, $recipe_extensions);
+ $this->assertEquals(1, array_count_values($recipes_names)['Install node with config']);
+ $this->assertEquals(1, array_count_values($recipe_extensions)['field']);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
new file mode 100644
index 00000000000..165fb07de23
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\RecipeDiscovery;
+use Drupal\Core\Recipe\UnknownRecipeException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeDiscovery
+ * @group Recipe
+ */
+class RecipeDiscoveryTest extends KernelTestBase {
+
+ /**
+ * Tests that recipe discovery can find recipes.
+ *
+ * @testWith ["install_two_modules", "Install two modules"]
+ * ["recipe_include", "Recipe include"]
+ */
+ public function testRecipeDiscovery(string $recipe, string $name): void {
+ $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+ $recipe = $discovery->getRecipe($recipe);
+ $this->assertSame($name, $recipe->name);
+ }
+
+ /**
+ * Tests the exception thrown when recipe discovery cannot find a recipe.
+ *
+ * @testWith ["no_recipe"]
+ * ["does_not_exist"]
+ */
+ public function testRecipeDiscoveryException(string $recipe): void {
+ $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+ try {
+ $discovery->getRecipe($recipe);
+ $this->fail('Expected exception not thrown');
+ }
+ catch (UnknownRecipeException $e) {
+ $this->assertSame($recipe, $e->recipe);
+ $this->assertSame('core/tests/fixtures/recipes', $e->searchPath);
+ $this->assertSame('Can not find the ' . $recipe . ' recipe, search path: ' . $e->searchPath, $e->getMessage());
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php
new file mode 100644
index 00000000000..20d12bae4c3
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeAppliedEvent;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * @group Recipe
+ */
+class RecipeEventsTest extends KernelTestBase implements EventSubscriberInterface {
+
+ /**
+ * The human-readable names of the recipes that have been applied.
+ *
+ * @var string[]
+ */
+ private array $recipesApplied = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents(): array {
+ return [
+ RecipeAppliedEvent::class => 'onRecipeApply',
+ ];
+ }
+
+ public function onRecipeApply(RecipeAppliedEvent $event): void {
+ $this->recipesApplied[] = $event->recipe->name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function register(ContainerBuilder $container): void {
+ parent::register($container);
+
+ // Every time the container is rebuilt, ensure this object is subscribing to
+ // events.
+ $container->getDefinition('event_dispatcher')
+ ->addMethodCall('addSubscriber', [$this]);
+ }
+
+ public function testRecipeAppliedEvent(): void {
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include');
+ RecipeRunner::processRecipe($recipe);
+
+ $this->assertSame(['Install node with config', 'Recipe include'], $this->recipesApplied);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
new file mode 100644
index 00000000000..0af066b626c
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
@@ -0,0 +1,254 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipePreExistingConfigException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+use Drupal\views\Entity\View;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeRunner
+ * @group Recipe
+ */
+class RecipeRunnerTest extends KernelTestBase {
+
+ use RecipeTestTrait;
+
+ public function testModuleInstall(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertFalse($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is not installed');
+ $this->assertFalse($this->container->get('module_handler')->moduleExists('text'), 'The text module is not installed');
+ $this->assertFalse($this->container->get('module_handler')->moduleExists('node'), 'The node module is not installed');
+ $this->assertFalse($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration does not exist');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_two_modules');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after applying the recipe.
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is installed');
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('text'), 'The text module is installed');
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+ $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
+ }
+
+ public function testModuleAndThemeInstall(): void {
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/base_theme_and_views');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after applying the recipe.
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('views'), 'The views module is installed');
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_basetheme'), 'The test_basetheme theme is installed');
+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subtheme'), 'The test_subtheme theme is installed');
+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subsubtheme'), 'The test_subsubtheme theme is installed');
+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+ $this->assertFalse($this->container->get('config.storage')->exists('views.view.archive'), 'The views.view.archive configuration has not been created');
+ $this->assertEmpty(View::loadMultiple(), "No views exist");
+ }
+
+ public function testThemeModuleDependenciesInstall(): void {
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/theme_with_module_dependencies');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after applying the recipe.
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('test_module_required_by_theme'), 'The test_module_required_by_theme module is installed');
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('test_another_module_required_by_theme'), 'The test_another_module_required_by_theme module is installed');
+ $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'The test_theme_depending_on_modules theme is installed');
+ }
+
+ public function testModuleConfigurationOverride(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after applying the recipe.
+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+ $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+ $this->assertTrue($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to TRUE');
+ $this->assertSame('Test content type', NodeType::load('test')?->label());
+ $node_type_data = $this->config('node.type.test')->get();
+ $this->assertGreaterThan(0, strlen($node_type_data['uuid']), 'The node type configuration has been assigned a UUID.');
+ // cSpell:disable-next-line
+ $this->assertSame('8Jlq8CmNXHVtNIHBHgFGpnAKthlUz0XoW_D0g56QXqY', $node_type_data['_core']['default_config_hash']);
+ }
+
+ public function testApplySameRecipe(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state prior to applying the recipe.
+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('node.'), 'There is node configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ RecipeRunner::processRecipe($recipe);
+ $this->assertTrue(TRUE, 'Applying a recipe for the second time with no config changes results in a successful application');
+
+ $type = NodeType::load('test');
+ $type->setNewRevision(FALSE);
+ $type->save();
+
+ $this->expectException(RecipePreExistingConfigException::class);
+ $this->expectExceptionMessage("The configuration 'node.type.test' exists already and does not match the recipe's configuration");
+ Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ }
+
+ public function testConfigFromModule(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+ $this->assertSame(['dotted.default', 'override'], array_keys($config_test_entities));
+ }
+
+ public function testConfigWildcard(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_wildcard');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+ $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
+ $this->assertSame('Default', $config_test_entities['dotted.default']->label());
+ $this->assertSame('herp', $this->config('config_test.system')->get('404'));
+ }
+
+ public function testConfigFromModuleAndRecipe(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module_and_recipe');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+ $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+ $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
+ $this->assertSame('Provided by recipe', $config_test_entities['dotted.default']->label());
+ $this->assertSame('foo', $this->config('config_test.system')->get('404'));
+ }
+
+ public function testRecipeInclude(): void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+ $this->assertFalse($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module not installed');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed');
+ $this->assertSame('Test content type', NodeType::load('test')?->label());
+ $this->assertSame('Another test content type', NodeType::load('another_test')?->label());
+ }
+
+ public function testConfigActions() :void {
+ // Test the state prior to applying the recipe.
+ $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+ $config_test_entity = $storage->load('recipe');
+ $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+ $this->assertSame('Created by recipe', $config_test_entity->label());
+ $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
+ $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
+ }
+
+ public function testConfigActionsPreExistingConfig() :void {
+ $this->enableModules(['config_test']);
+ $this->installConfig(['config_test']);
+ $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
+ $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+ $config_test_entity = $storage->create(['id' => 'recipe', 'label' => 'Created by test']);
+ $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+ $config_test_entity->setProtectedProperty('Set by test');
+ $config_test_entity->save();
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $config_test_entity = $storage->load('recipe');
+ $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+ $this->assertSame('Created by test', $config_test_entity->label());
+ $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
+ $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
+ }
+
+ public function testInvalidConfigAction() :void {
+ $recipe_data = <<<YAML
+name: Invalid config action
+install:
+ - config_test
+config:
+ actions:
+ config_test.dynamic.recipe:
+ ensure_exists:
+ label: 'Created by recipe'
+ setBody: 'Description set by recipe'
+YAML;
+
+ $recipe = $this->createRecipe($recipe_data);
+ $this->expectException(PluginNotFoundException::class);
+ $this->expectExceptionMessage('The "setBody" plugin does not exist.');
+ RecipeRunner::processRecipe($recipe);
+ }
+
+ public function testRecipesAreDisambiguatedByPath(): void {
+ $recipe_data = <<<YAML
+name: 'Recipe include'
+recipes:
+ - core/tests/fixtures/recipes/recipe_include
+install:
+ - config_test
+YAML;
+
+ $recipe = $this->createRecipe($recipe_data, 'recipe_include');
+ RecipeRunner::processRecipe($recipe);
+
+ // Test the state after to applying the recipe.
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed');
+ $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'), 'Config test module installed');
+ $this->assertSame('Test content type', NodeType::load('test')?->label());
+ $this->assertSame('Another test content type', NodeType::load('another_test')?->label());
+
+ $operations = RecipeRunner::toBatchOperations($recipe);
+ $this->assertSame('triggerEvent', $operations[7][0][1]);
+ $this->assertSame('Install node with config', $operations[7][1][0]->name);
+ $this->assertStringEndsWith('core/tests/fixtures/recipes/install_node_with_config', $operations[7][1][0]->path);
+
+ $this->assertSame('triggerEvent', $operations[10][0][1]);
+ $this->assertSame('Recipe include', $operations[10][1][0]->name);
+ $this->assertStringEndsWith('core/tests/fixtures/recipes/recipe_include', $operations[10][1][0]->path);
+
+ $this->assertSame('triggerEvent', $operations[12][0][1]);
+ $this->assertSame('Recipe include', $operations[12][1][0]->name);
+ $this->assertSame($this->siteDirectory . '/recipes/recipe_include', $operations[12][1][0]->path);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
new file mode 100644
index 00000000000..7df23e467ac
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\Core\Recipe\RecipePreExistingConfigException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\Recipe
+ * @group Recipe
+ */
+class RecipeTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['system', 'user', 'field'];
+
+ /**
+ * @testWith ["no_extensions", "No extensions" , "Testing", [], "A recipe description"]
+ * ["install_two_modules", "Install two modules" , "Content type", ["filter", "text", "node"], ""]
+ */
+ public function testCreateFromDirectory2(string $recipe_name, string $expected_name, string $expected_type, array $expected_modules, string $expected_description): void {
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/' . $recipe_name);
+ $this->assertSame($expected_name, $recipe->name);
+ $this->assertSame($expected_type, $recipe->type);
+ $this->assertSame($expected_modules, $recipe->install->modules);
+ $this->assertSame($expected_description, $recipe->description);
+ }
+
+ public function testCreateFromDirectoryNoRecipe(): void {
+ $dir = uniqid('public://');
+ mkdir($dir);
+
+ $this->expectException(RecipeFileException::class);
+ $this->expectExceptionMessage('There is no ' . $dir . '/recipe.yml file');
+ Recipe::createFromDirectory($dir);
+ }
+
+ public function testPreExistingDifferentConfiguration(): void {
+ // Install the node module, its dependencies and configuration.
+ $this->container->get('module_installer')->install(['node']);
+ $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
+
+ try {
+ Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ $this->fail('Expected exception not thrown');
+ }
+ catch (RecipePreExistingConfigException $e) {
+ $this->assertSame("The configuration 'node.settings' exists already and does not match the recipe's configuration", $e->getMessage());
+ $this->assertSame('node.settings', $e->configName);
+ }
+ }
+
+ public function testPreExistingMatchingConfiguration(): void {
+ // Install the node module, its dependencies and configuration.
+ $this->container->get('module_installer')->install(['node']);
+ // Change the config to match the recipe's config to prevent the exception
+ // being thrown.
+ $this->config('node.settings')->set('use_admin_theme', TRUE)->save();
+
+ $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+ $this->assertSame('core/tests/fixtures/recipes/install_node_with_config/config', $recipe->config->recipeConfigDirectory);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php
new file mode 100644
index 00000000000..20551fb972f
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php
@@ -0,0 +1,340 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class RecipeValidationTest extends KernelTestBase {
+
+ /**
+ * Data provider for ::testRecipeValidation().
+ *
+ * @return \Generator
+ * The test cases.
+ */
+ public static function providerRecipeValidation(): iterable {
+ yield 'name is correct' => [
+ 'name: Correct name',
+ NULL,
+ ];
+ yield 'name missing' => [
+ '{}',
+ [
+ '[name]' => ['This field is missing.'],
+ ],
+ ];
+ yield 'name is not a string' => [
+ 'name: 39',
+ [
+ '[name]' => ['This value should be of type string.'],
+ ],
+ ];
+ yield 'name is null' => [
+ 'name: ~',
+ [
+ '[name]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'name is blank' => [
+ "name: ''",
+ [
+ '[name]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'name has invalid characters' => [
+ <<<YAML
+name: |
+ My
+ Amazing Recipe
+YAML,
+ [
+ '[name]' => ['Recipe names cannot span multiple lines or contain control characters.'],
+ ],
+ ];
+ yield 'description is correct' => [
+ <<<YAML
+name: Correct description
+description: 'This is the correct description of a recipe.'
+YAML,
+ NULL,
+ ];
+ yield 'description is not a string' => [
+ <<<YAML
+name: Bad description
+description: [Nope!]
+YAML,
+ [
+ '[description]' => ['This value should be of type string.'],
+ ],
+ ];
+ yield 'description is blank' => [
+ <<<YAML
+name: Blank description
+description: ''
+YAML,
+ [
+ '[description]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'description is null' => [
+ <<<YAML
+name: Null description
+description: ~
+YAML,
+ [
+ '[description]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'description contains control characters' => [
+ <<<YAML
+name: Bad description
+description: "I have a\b bad character."
+YAML,
+ [
+ '[description]' => ['The recipe description cannot contain control characters, only visible characters.'],
+ ],
+ ];
+ yield 'type is correct' => [
+ <<<YAML
+name: Correct type
+type: Testing
+YAML,
+ NULL,
+ ];
+ yield 'type is not a string' => [
+ <<<YAML
+name: Bad type
+type: 39
+YAML,
+ [
+ '[type]' => ['This value should be of type string.'],
+ ],
+ ];
+ yield 'type is blank' => [
+ <<<YAML
+name: Blank type
+type: ''
+YAML,
+ [
+ '[type]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'type is null' => [
+ <<<YAML
+name: Null type
+type: ~
+YAML,
+ [
+ '[type]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'type has invalid characters' => [
+ <<<YAML
+name: Invalid type
+type: |
+ My
+ Amazing Recipe
+YAML,
+ [
+ '[type]' => ['Recipe type cannot span multiple lines or contain control characters.'],
+ ],
+ ];
+ // @todo Test valid recipe once https://www.drupal.org/i/3421197 is in.
+ yield 'recipes list is scalar' => [
+ <<<YAML
+name: Bad recipe list
+recipes: 39
+YAML,
+ [
+ '[recipes]' => ['This value should be of type iterable.'],
+ ],
+ ];
+ yield 'recipes list has a blank entry' => [
+ <<<YAML
+name: Invalid recipe
+recipes: ['']
+YAML,
+ [
+ '[recipes][0]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'recipes list has a non-existent recipe' => [
+ <<<YAML
+name: Non-existent recipe
+recipes:
+ - vaporware
+YAML,
+ [
+ '[recipes][0]' => ['The vaporware recipe does not exist.'],
+ ],
+ ];
+ yield 'recipe depends on itself' => [
+ <<<YAML
+name: 'Inception'
+recipes:
+ - no_extensions
+YAML,
+ [
+ '[recipes][0]' => ['The "no_extensions" recipe cannot depend on itself.'],
+ ],
+ 'no_extensions',
+ ];
+ yield 'extension list is scalar' => [
+ <<<YAML
+name: Bad extension list
+install: 39
+YAML,
+ [
+ '[install]' => ['This value should be of type iterable.'],
+ ],
+ ];
+ yield 'extension list has a blank entry' => [
+ <<<YAML
+name: Blank extension list
+install: ['']
+YAML,
+ [
+ '[install][0]' => ['This value should not be blank.'],
+ ],
+ ];
+ yield 'installing unknown extensions' => [
+ <<<YAML
+name: 'Unknown extensions'
+install:
+ - config test
+ - drupal:color
+YAML,
+ [
+ '[install][0]' => ['"config test" is not a known module or theme.'],
+ '[install][1]' => ['"color" is not a known module or theme.'],
+ ],
+ ];
+ yield 'only installs extension' => [
+ <<<YAML
+name: 'Only installs extensions'
+install:
+ - filter
+ - drupal:claro
+YAML,
+ NULL,
+ ];
+ yield 'config import list is valid' => [
+ <<<YAML
+name: 'Correct config import list'
+config:
+ import:
+ config_test: '*'
+ claro:
+ - claro.settings
+YAML,
+ NULL,
+ ];
+ yield 'config import list is scalar' => [
+ <<<YAML
+name: 'Bad config import list'
+config:
+ import: 23
+YAML,
+ [
+ '[config][import]' => ['This value should be of type iterable.'],
+ ],
+ ];
+ yield 'config import list has a blank entry' => [
+ <<<YAML
+name: Blank config import list
+config:
+ import: ['']
+YAML,
+ [
+ '[config][import][0]' => ['This value should satisfy at least one of the following constraints: [1] This value should be identical to string "*". [2] Each element of this collection should satisfy its own set of constraints.'],
+ ],
+ ];
+ yield 'config actions list is valid' => [
+ <<<YAML
+name: 'Correct config actions list'
+install:
+ - config_test
+config:
+ actions:
+ config_test.dynamic.recipe:
+ ensure_exists:
+ label: 'Created by recipe'
+ setProtectedProperty: 'Set by recipe'
+YAML,
+ NULL,
+ ];
+ yield 'config actions list is scalar' => [
+ <<<YAML
+name: 'Bad config actions list'
+config:
+ actions: 23
+YAML,
+ [
+ '[config][actions]' => ['This value should be of type iterable.'],
+ ],
+ ];
+ yield 'config actions list has a blank entry' => [
+ <<<YAML
+name: Blank config actions list
+config:
+ actions: ['']
+YAML,
+ [
+ '[config][actions][0]' => [
+ 'This value should be of type array.',
+ 'This value should not be blank.',
+ 'Config actions cannot be applied to 0 because the 0 extension is not installed, and is not installed by this recipe or any of the recipes it depends on.',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Tests the validation of recipe.yml file.
+ *
+ * @param string $recipe
+ * The contents of the `recipe.yml` file.
+ * @param string[][]|null $expected_violations
+ * (Optional) The expected validation violations, keyed by property path.
+ * Each value should be an array of error messages expected for that
+ * property.
+ * @param string|null $recipe_name
+ * (optional) The name of the directory containing `recipe.yml`, or NULL to
+ * randomly generate one.
+ *
+ * @dataProvider providerRecipeValidation
+ */
+ public function testRecipeValidation(string $recipe, ?array $expected_violations, ?string $recipe_name = NULL): void {
+ $dir = 'public://' . ($recipe_name ?? uniqid());
+ mkdir($dir);
+ file_put_contents($dir . '/recipe.yml', $recipe);
+
+ try {
+ Recipe::createFromDirectory($dir);
+ // If there was no error, we'd better not have been expecting any.
+ $this->assertNull($expected_violations, 'Validation errors were expected, but there were none.');
+ }
+ catch (RecipeFileException $e) {
+ $this->assertIsArray($expected_violations, 'There were validation errors, but none were expected.');
+ $this->assertIsObject($e->violations);
+
+ $actual_violations = [];
+ /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
+ foreach ($e->violations as $violation) {
+ $property_path = $violation->getPropertyPath();
+ $actual_violations[$property_path][] = (string) $violation->getMessage();
+ }
+ ksort($actual_violations);
+ ksort($expected_violations);
+ $this->assertSame($expected_violations, $actual_violations);
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php
new file mode 100644
index 00000000000..7a9ea5a2287
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @group Recipe
+ */
+class RollbackTest extends BrowserTestBase {
+
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ *
+ * Disable strict config schema because this test explicitly makes the
+ * recipe system save invalid config, to prove that it validates it after
+ * the fact and raises an error.
+ */
+ protected $strictConfigSchema = FALSE;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'system',
+ 'user',
+ ];
+
+ /**
+ * @testWith ["invalid_config", "core.date_format.invalid"]
+ * ["recipe_depend_on_invalid", "core.date_format.invalid"]
+ * ["recipe_depend_on_invalid_config_and_valid_modules", "core.date_format.invalid"]
+ */
+ public function testRollbackForInvalidConfig(string $recipe_fixture, string $expected_invalid_config_name): void {
+ $expected_core_extension_modules = $this->config('core.extension')->get('module');
+
+ /** @var string $recipe_fixture */
+ $recipe_fixture = realpath(__DIR__ . "/../../../../fixtures/recipes/$recipe_fixture");
+ $process = $this->applyRecipe($recipe_fixture, 1);
+ $this->assertStringContainsString("There were validation errors in $expected_invalid_config_name:", $process->getErrorOutput());
+ $this->assertCheckpointsExist([
+ "Backup before the '" . Recipe::createFromDirectory($recipe_fixture)->name . "' recipe.",
+ ]);
+
+ // @see invalid_config
+ $date_formats = DateFormat::loadMultiple(['valid', 'invalid']);
+ $this->assertEmpty($date_formats, "The recipe's imported config was not rolled back.");
+
+ // @see recipe_depend_on_invalid_config_and_valid_module
+ $this->assertSame($expected_core_extension_modules, $this->config('core.extension')->get('module'));
+ }
+
+ /**
+ * Asserts that the current set of checkpoints matches the given labels.
+ *
+ * @param string[] $expected_labels
+ * The labels of every checkpoint that is expected to exist currently, in
+ * the expected order.
+ */
+ private function assertCheckpointsExist(array $expected_labels): void {
+ $checkpoints = \Drupal::service('config.checkpoints');
+ $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
+ $this->assertSame($expected_labels, array_values($labels));
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
new file mode 100644
index 00000000000..29b1a7e784e
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\entity_test\Entity\EntityTestBundle;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * Tests config actions targeting multiple entities using wildcards.
+ *
+ * @group Recipe
+ */
+class WildcardConfigActionsTest extends KernelTestBase {
+
+ use ContentTypeCreationTrait;
+ use RecipeTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'entity_test',
+ 'field',
+ 'node',
+ 'system',
+ 'text',
+ 'user',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->installConfig('node');
+
+ $this->createContentType(['type' => 'one']);
+ $this->createContentType(['type' => 'two']);
+
+ EntityTestBundle::create(['id' => 'one'])->save();
+ EntityTestBundle::create(['id' => 'two'])->save();
+
+ $field_storage = FieldStorageConfig::create([
+ 'entity_type' => 'entity_test_with_bundle',
+ 'field_name' => 'field_test',
+ 'type' => 'boolean',
+ ]);
+ $field_storage->save();
+ FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one'])
+ ->save();
+ FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two'])
+ ->save();
+
+ $field_storage = FieldStorageConfig::create([
+ 'entity_type' => 'node',
+ 'field_name' => 'field_test',
+ 'type' => 'boolean',
+ ]);
+ $field_storage->save();
+ FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one'])
+ ->save();
+ FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two'])
+ ->save();
+ }
+
+ /**
+ * Tests targeting multiple config entities for an action, using wildcards.
+ *
+ * @param string $expression
+ * The expression the recipe will use to target multiple config entities.
+ * @param string[] $expected_changed_entities
+ * The IDs of the config entities that we expect the recipe to change.
+ *
+ * @testWith ["field.field.node.one.*", ["node.one.body", "node.one.field_test"]]
+ * ["field.field.node.*.body", ["node.one.body", "node.two.body"]]
+ * ["field.field.*.one.field_test", ["entity_test_with_bundle.one.field_test", "node.one.field_test"]]
+ * ["field.field.node.*.*", ["node.one.body", "node.one.field_test", "node.two.body", "node.two.field_test"]]
+ * ["field.field.*.one.*", ["entity_test_with_bundle.one.field_test", "node.one.field_test", "node.one.body"]]
+ * ["field.field.*.*.field_test", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test"]]
+ * ["field.field.*.*.*", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test", "node.one.body", "node.two.body"]]
+ */
+ public function testTargetEntitiesByWildcards(string $expression, array $expected_changed_entities): void {
+ $contents = <<<YAML
+name: 'Wildcards!'
+config:
+ actions:
+ $expression:
+ setLabel: 'Changed by config action'
+YAML;
+
+ $recipe = $this->createRecipe($contents);
+ RecipeRunner::processRecipe($recipe);
+
+ $changed = $this->container->get(EntityTypeManagerInterface::class)
+ ->getStorage('field_config')
+ ->getQuery()
+ ->condition('label', 'Changed by config action')
+ ->execute();
+ sort($expected_changed_entities);
+ sort($changed);
+ $this->assertSame($expected_changed_entities, array_values($changed));
+ }
+
+ /**
+ * Tests that an invalid wildcard expression will raise an error.
+ *
+ * @testWith ["field.*.node.one.*", "No installed config entity type uses the prefix in the expression 'field.*.node.one.*'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe."]
+ * ["field.field.node.*.body/", " could not be parsed."]
+ */
+ public function testInvalidExpression(string $expression, string $expected_exception_message): void {
+ $contents = <<<YAML
+name: 'Wildcards gone wild...'
+config:
+ actions:
+ $expression:
+ simple_config_update:
+ label: 'Changed by config action'
+YAML;
+ $recipe = $this->createRecipe($contents);
+
+ $this->expectException(ConfigActionException::class);
+ $this->expectExceptionMessage($expected_exception_message);
+ RecipeRunner::processRecipe($recipe);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
index 735624438ae..535b1d8c316 100644
--- a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
+++ b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
@@ -235,7 +235,7 @@ class QuickStartTest extends TestCase {
];
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
$process->run();
- $this->assertStringContainsString('\'umami\' is not a valid install profile. Did you mean \'demo_umami\'?', $process->getErrorOutput());
+ $this->assertMatchesRegularExpression("/'umami' is not a valid install profile or recipe\. Did you mean \W*'demo_umami'?/", $process->getErrorOutput());
}
/**
diff --git a/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php
new file mode 100644
index 00000000000..e61f3c16e48
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Action;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Action\Attribute\ConfigAction
+ * @group Config
+ */
+class ConfigActionAttributeTest extends UnitTestCase {
+
+ /**
+ * @covers ::__construct
+ */
+ public function testNoLabelNoDeriver(): void {
+ $this->expectException(InvalidPluginDefinitionException::class);
+ $this->expectExceptionMessage("The 'test' config action plugin must have either an admin label or a deriver");
+ new ConfigAction('test');
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php
new file mode 100644
index 00000000000..1e1064dcdf2
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php
@@ -0,0 +1,296 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\Time;
+use Drupal\Core\Cache\NullBackend;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\Checkpoint\LinearHistory;
+use Drupal\Core\Config\Checkpoint\CheckpointStorage;
+use Drupal\Core\Config\Config;
+use Drupal\Core\Config\ConfigCrudEvent;
+use Drupal\Core\Config\MemoryStorage;
+use Drupal\Core\Config\StorageCopyTrait;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
+use Drupal\Core\Lock\NullLockBackend;
+use Drupal\Core\State\State;
+use Drupal\Tests\UnitTestCase;
+use Drupal\TestTools\Random;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Checkpoint\CheckpointStorage
+ * @group Config
+ */
+class CheckpointStorageTest extends UnitTestCase {
+
+ use StorageCopyTrait;
+
+ /**
+ * The memory storage containing the data.
+ *
+ * @var \Drupal\Core\Config\MemoryStorage
+ */
+ protected MemoryStorage $memory;
+
+ /**
+ * The checkpoint storage under test.
+ *
+ * @var \Drupal\Core\Config\Checkpoint\CheckpointStorage
+ */
+ protected CheckpointStorage $storage;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ // Set up a memory storage we can manipulate to set fixtures.
+ $this->memory = new MemoryStorage();
+ $keyValueMemoryFactory = new KeyValueMemoryFactory();
+ $state = new State($keyValueMemoryFactory, new NullBackend('test'), new NullLockBackend());
+ $time = new Time();
+ $checkpoints = new LinearHistory($state, $time);
+ $this->storage = new CheckpointStorage($this->memory, $checkpoints, $keyValueMemoryFactory);
+ }
+
+ /**
+ * @covers ::checkpoint
+ * @covers \Drupal\Core\Config\Checkpoint\Checkpoint
+ */
+ public function testCheckpointCreation(): void {
+ $checkpoint = $this->storage->checkpoint('Test');
+ $this->assertInstanceOf(Checkpoint::class, $checkpoint);
+ $this->assertSame('Test', $checkpoint->label);
+
+ $checkpoint2 = $this->storage->checkpoint('This will not make a checkpoint because nothing has changed');
+ $this->assertSame($checkpoint2, $checkpoint);
+ $config = $this->prophesize(Config::class);
+ $config->getName()->willReturn('test.config');
+ $config->getOriginal('', FALSE)->willReturn([]);
+ $config->getRawData()->willReturn(['foo' => 'bar']);
+ $config->getStorage()->willReturn($this->storage);
+ $event = new ConfigCrudEvent($config->reveal());
+ $this->storage->onConfigSaveAndDelete($event);
+
+ $checkpoint3 = $this->storage->checkpoint('Created test.config');
+ $this->assertNotSame($checkpoint3, $checkpoint);
+ $this->assertSame('Created test.config', $checkpoint3->label);
+
+ $checkpoint4 = $this->storage->checkpoint('This will not create a checkpoint either');
+ $this->assertSame($checkpoint4, $checkpoint3);
+
+ // Simulate a save with no change.
+ $config = $this->prophesize(Config::class);
+ $config->getName()->willReturn('test.config');
+ $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']);
+ $config->getRawData()->willReturn(['foo' => 'bar']);
+ $config->getStorage()->willReturn($this->storage);
+ $event = new ConfigCrudEvent($config->reveal());
+ $this->storage->onConfigSaveAndDelete($event);
+
+ $checkpoint5 = $this->storage->checkpoint('Save with no change');
+ $this->assertSame($checkpoint5, $checkpoint3);
+
+ // Create collection and ensure that checkpoints are kept in sync.
+ $collection = $this->storage->createCollection('test');
+ $config = $this->prophesize(Config::class);
+ $config->getName()->willReturn('test.config');
+ $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']);
+ $config->getRawData()->willReturn(['foo' => 'collection_bar']);
+ $config->getStorage()->willReturn($collection);
+ $event = new ConfigCrudEvent($config->reveal());
+ $collection->onConfigSaveAndDelete($event);
+
+ $checkpoint6 = $this->storage->checkpoint('Save in collection');
+ $this->assertNotSame($checkpoint6, $checkpoint3);
+ $this->assertSame($collection->checkpoint('Calling checkpoint on collection'), $checkpoint6);
+ }
+
+ /**
+ * @covers ::exists
+ * @covers ::read
+ * @covers ::readMultiple
+ * @covers ::listAll
+ *
+ * @dataProvider readMethodsProvider
+ */
+ public function testReadOperations(string $method, array $arguments, array $fixture): void {
+ // Create a checkpoint so the checkpoint storage can be read from.
+ $this->storage->checkpoint('');
+ $this->setRandomFixtureConfig($fixture);
+
+ $expected = call_user_func_array([$this->memory, $method], $arguments);
+ $actual = call_user_func_array([$this->storage, $method], $arguments);
+ $this->assertEquals($expected, $actual);
+ }
+
+ /**
+ * Provide the methods that work transparently.
+ *
+ * @return array
+ * The data.
+ */
+ public static function readMethodsProvider(): array {
+ $fixture = [
+ StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b', 'other.a'],
+ ];
+
+ $data = [];
+ $data[] = ['exists', ['config.a'], $fixture];
+ $data[] = ['exists', ['not.existing'], $fixture];
+ $data[] = ['read', ['config.a'], $fixture];
+ $data[] = ['read', ['not.existing'], $fixture];
+ $data[] = ['readMultiple', [['config.a', 'config.b', 'not']], $fixture];
+ $data[] = ['listAll', [''], $fixture];
+ $data[] = ['listAll', ['config'], $fixture];
+ $data[] = ['listAll', ['none'], $fixture];
+
+ return $data;
+ }
+
+ /**
+ * @covers ::write
+ * @covers ::delete
+ * @covers ::rename
+ * @covers ::deleteAll
+ *
+ * @dataProvider writeMethodsProvider
+ */
+ public function testWriteOperations(string $method, array $arguments, array $fixture): void {
+ $this->setRandomFixtureConfig($fixture);
+
+ // Create an independent memory storage as a backup.
+ $backup = new MemoryStorage();
+ static::replaceStorageContents($this->memory, $backup);
+
+ try {
+ call_user_func_array([$this->storage, $method], $arguments);
+ $this->fail("exception not thrown");
+ }
+ catch (\BadMethodCallException $exception) {
+ $this->assertEquals(CheckpointStorage::class . '::' . $method . ' is not allowed on a CheckpointStorage', $exception->getMessage());
+ }
+
+ // Assert that the memory storage has not been altered.
+ $this->assertEquals($backup, $this->memory);
+ }
+
+ /**
+ * Provide the methods that throw an exception.
+ *
+ * @return array
+ * The data
+ */
+ public static function writeMethodsProvider(): array {
+ $fixture = [
+ StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b'],
+ ];
+
+ $data = [];
+ $data[] = ['write', ['config.a', (array) Random::getGenerator()->object()], $fixture];
+ $data[] = ['write', [Random::MachineName(), (array) Random::getGenerator()->object()], $fixture];
+ $data[] = ['delete', ['config.a'], $fixture];
+ $data[] = ['delete', [Random::MachineName()], $fixture];
+ $data[] = ['rename', ['config.a', 'config.b'], $fixture];
+ $data[] = ['rename', ['config.a', Random::MachineName()], $fixture];
+ $data[] = ['rename', [Random::MachineName(), Random::MachineName()], $fixture];
+ $data[] = ['deleteAll', [''], $fixture];
+ $data[] = ['deleteAll', ['config'], $fixture];
+ $data[] = ['deleteAll', ['other'], $fixture];
+
+ return $data;
+ }
+
+ /**
+ * @covers ::getAllCollectionNames
+ * @covers ::getCollectionName
+ * @covers ::createCollection
+ */
+ public function testCollections(): void {
+ $ref_readFromCheckpoint = new \ReflectionProperty($this->storage, 'readFromCheckpoint');
+
+ // Create some checkpoints so the checkpoint storage can be read from.
+ $checkpoint1 = $this->storage->checkpoint('1');
+ $config = $this->prophesize(Config::class);
+ $config->getName()->willReturn('test.config');
+ $config->getOriginal('', FALSE)->willReturn([]);
+ $config->getRawData()->willReturn(['foo' => 'bar']);
+ $config->getStorage()->willReturn($this->storage);
+ $event = new ConfigCrudEvent($config->reveal());
+ $this->storage->onConfigSaveAndDelete($event);
+ $checkpoint2 = $this->storage->checkpoint('2');
+
+ $fixture = [
+ StorageInterface::DEFAULT_COLLECTION => [$this->randomMachineName()],
+ 'A' => [$this->randomMachineName()],
+ 'B' => [$this->randomMachineName()],
+ 'C' => [$this->randomMachineName()],
+ ];
+ $this->setRandomFixtureConfig($fixture);
+
+ $this->assertEquals(['A', 'B', 'C'], $this->storage->getAllCollectionNames());
+ foreach (array_keys($fixture) as $collection) {
+ $storage = $this->storage->createCollection($collection);
+ // Assert that the collection storage is still a checkpoint storage.
+ $this->assertInstanceOf(CheckpointStorage::class, $storage);
+ $this->assertEquals($collection, $storage->getCollectionName());
+
+ // Ensure that the
+ // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint
+ // property is kept in sync.
+ $this->storage->setCheckpointToReadFrom($checkpoint2);
+ $this->assertSame($checkpoint2->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id);
+ if (isset($previous_collection)) {
+ $previous_collection->setCheckpointToReadFrom($checkpoint1);
+ $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id);
+ $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($this->storage->createCollection($collection))?->id);
+ }
+
+ // Save the storage in a variable so we can test use
+ // setCheckpointToReadFrom() on it.
+ $previous_collection = $storage;
+ }
+ }
+
+ /**
+ * @covers ::encode
+ * @covers ::decode
+ */
+ public function testEncodeDecode(): void {
+ $array = (array) $this->getRandomGenerator()->object();
+ $string = $this->getRandomGenerator()->string();
+
+ // Assert reversibility of encoding and decoding.
+ $this->assertEquals($array, $this->storage->decode($this->storage->encode($array)));
+ $this->assertEquals($string, $this->storage->encode($this->storage->decode($string)));
+ // Assert same results as the decorated storage.
+ $this->assertEquals($this->memory->encode($array), $this->storage->encode($array));
+ $this->assertEquals($this->memory->decode($string), $this->storage->decode($string));
+ }
+
+ /**
+ * Generate random config in the memory storage.
+ *
+ * @param array $config
+ * The config keys, keyed by the collection.
+ */
+ protected function setRandomFixtureConfig(array $config): void {
+ // Erase previous fixture.
+ foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $this->memory->getAllCollectionNames()) as $collection) {
+ $this->memory->createCollection($collection)->deleteAll();
+ }
+
+ foreach ($config as $collection => $keys) {
+ $storage = $this->memory->createCollection($collection);
+ foreach ($keys as $key) {
+ // Create some random config.
+ $storage->write($key, (array) $this->getRandomGenerator()->object());
+ }
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php
new file mode 100644
index 00000000000..1a3ee6cd7cc
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php
@@ -0,0 +1,190 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\Checkpoint\CheckpointExistsException;
+use Drupal\Core\Config\Checkpoint\UnknownCheckpointException;
+use Drupal\Core\Config\Checkpoint\LinearHistory;
+use Drupal\Core\State\StateInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Checkpoint\LinearHistory
+ * @group Config
+ */
+class LinearHistoryTest extends UnitTestCase {
+
+ /**
+ * The key used store of all the checkpoint names in state.
+ *
+ * @see \Drupal\Core\Config\Checkpoint\Checkpoints::CHECKPOINT_KEY
+ */
+ private const CHECKPOINT_KEY = 'config.checkpoints';
+
+ /**
+ * @covers ::add
+ * @covers ::count
+ * @covers ::getActiveCheckpoint
+ * @covers \Drupal\Core\Config\Checkpoint\Checkpoint
+ */
+ public function testAdd(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+ $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL);
+ $time = $this->prophesize(TimeInterface::class);
+ $time->getCurrentTime()->willReturn(1701539520, 1701539994);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->assertCount(0, $checkpoints);
+ $this->assertNull($checkpoints->getActiveCheckpoint());
+
+ $checkpoint = $checkpoints->add('hash1', 'Label');
+
+ $this->assertSame('hash1', $checkpoint->id);
+ $this->assertSame('Label', $checkpoint->label);
+ $this->assertNull($checkpoint->parent);
+ $this->assertSame(1701539520, $checkpoint->timestamp);
+
+ $this->assertCount(1, $checkpoints);
+ $this->assertSame('hash1', $checkpoints->getActiveCheckpoint()?->id);
+
+ // Test that on the second call to add the ancestor is set correctly.
+ $checkpoint2 = $checkpoints->add('hash2', new FormattableMarkup('Another label', []));
+ $this->assertSame('hash2', $checkpoint2->id);
+ $this->assertSame('Another label', (string) $checkpoint2->label);
+ $this->assertSame($checkpoint->id, $checkpoint2->parent);
+ $this->assertSame(1701539994, $checkpoint2->timestamp);
+
+ $this->assertCount(2, $checkpoints);
+ $this->assertSame('hash2', $checkpoints->getActiveCheckpoint()?->id);
+
+ // Test that the checkpoints object can be iterated over.
+ $i = 0;
+ foreach ($checkpoints as $value) {
+ $i++;
+ $this->assertInstanceOf(Checkpoint::class, $value);
+ $this->assertSame('hash' . $i, $value->id);
+ }
+ }
+
+ /**
+ * @covers ::add
+ */
+ public function testAddException(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+ $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL);
+ $time = $this->prophesize(TimeInterface::class);
+ $time->getCurrentTime()->willReturn(1701539520, 1701539994);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+ $checkpoints->add('hash1', 'Label');
+ // Add another checkpoint with the same ID and an exception should be
+ // triggered.
+ $this->expectException(CheckpointExistsException::class);
+ $this->expectExceptionMessage('Cannot create a checkpoint with the ID "hash1" as it already exists');
+ $checkpoints->add('hash1', 'Label');
+ }
+
+ /**
+ * @covers ::delete
+ */
+ public function testDeleteAll(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn([
+ 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+ 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+ 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+ ]);
+ $state->delete(self::CHECKPOINT_KEY)->willReturn();
+ $time = $this->prophesize(TimeInterface::class);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->assertCount(3, $checkpoints);
+ $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+ $checkpoints->deleteAll();
+ $this->assertCount(0, $checkpoints);
+ $this->assertNull($checkpoints->getActiveCheckpoint());
+ }
+
+ /**
+ * @covers ::delete
+ */
+ public function testDelete(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $test_data = [
+ 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+ 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+ 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+ ];
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+ unset($test_data['hash1'], $test_data['hash2']);
+ $state->set(self::CHECKPOINT_KEY, $test_data)->willReturn();
+ $time = $this->prophesize(TimeInterface::class);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->assertCount(3, $checkpoints);
+ $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+ $checkpoints->delete('hash2');
+ $this->assertCount(1, $checkpoints);
+ $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+ }
+
+ /**
+ * @covers ::delete
+ */
+ public function testDeleteException(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+ $time = $this->prophesize(TimeInterface::class);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->expectException(UnknownCheckpointException::class);
+ $this->expectExceptionMessage('Cannot delete a checkpoint with the ID "foo" as it does not exist');
+
+ $checkpoints->delete('foo');
+ }
+
+ /**
+ * @covers ::getParents
+ */
+ public function testGetParents(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $test_data = [
+ 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+ 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+ 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+ ];
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+ $time = $this->prophesize(TimeInterface::class);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->assertSame(['hash2' => $test_data['hash2'], 'hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash3')));
+ $this->assertSame(['hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash2')));
+ $this->assertSame([], iterator_to_array($checkpoints->getParents('hash1')));
+ }
+
+ /**
+ * @covers ::getParents
+ */
+ public function testGetParentsException(): void {
+ $state = $this->prophesize(StateInterface::class);
+ $test_data = [
+ 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+ 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+ ];
+ $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+ $time = $this->prophesize(TimeInterface::class);
+ $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+ $this->expectException(UnknownCheckpointException::class);
+ $this->expectExceptionMessage('The checkpoint "hash3" does not exist');
+ iterator_to_array($checkpoints->getParents('hash3'));
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php
new file mode 100644
index 00000000000..6abfb5b6733
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\DefaultContent;
+
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\DefaultContent\ImportException;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\Finder
+ * @group DefaultContent
+ */
+class FinderTest extends UnitTestCase {
+
+ /**
+ * Tests that any discovered entity data is sorted into dependency order.
+ */
+ public function testFoundDataIsInDependencyOrder(): void {
+ $finder = new Finder(__DIR__ . '/../../../../fixtures/default_content');
+
+ $expected_order = [
+ // First is the author of the node.
+ '94503467-be7f-406c-9795-fc25baa22203',
+ // Next, the taxonomy term referenced by the node.
+ '550f86ad-aa11-4047-953f-636d42889f85',
+ // Then we have the node itself, since it has no other dependencies.
+ 'e1714f23-70c0-4493-8e92-af1901771921',
+ // Finally, the menu link to the node.
+ '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b',
+ ];
+ $this->assertSame($expected_order, array_slice(array_keys($finder->data), 0, 4));
+ }
+
+ /**
+ * Tests that files without UUIDs will raise an exception.
+ */
+ public function testExceptionIfNoUuid(): void {
+ $dir = FileSystem::getOsTemporaryDirectory();
+ $this->assertIsString($dir);
+ /** @var string $dir */
+ file_put_contents($dir . '/no-uuid.yml', '_meta: {}');
+
+ $this->expectException(ImportException::class);
+ $this->expectExceptionMessage("$dir/no-uuid.yml does not have a UUID.");
+ new Finder($dir);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
new file mode 100644
index 00000000000..5ff124fbe0a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
@@ -0,0 +1,306 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Recipe;
+
+use Drupal\Core\Config\MemoryStorage;
+use Drupal\Core\Config\NullStorage;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Recipe\RecipeConfigStorageWrapper;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeConfigStorageWrapper
+ * @group Recipe
+ */
+class RecipeConfigStorageWrapperTest extends UnitTestCase {
+
+ /**
+ * Validate that an empty set of storage backends returns null storage.
+ */
+ public function testNullStorage(): void {
+ $this->assertInstanceOf(
+ NullStorage::class,
+ RecipeConfigStorageWrapper::createStorageFromArray([])
+ );
+ }
+
+ /**
+ * Validate that a single storage returns exactly the same instance.
+ */
+ public function testSingleStorage(): void {
+ $storages = [new NullStorage()];
+ $this->assertSame(
+ $storages[0],
+ RecipeConfigStorageWrapper::createStorageFromArray($storages)
+ );
+ }
+
+ /**
+ * Validate that multiple storages return underlying values correctly.
+ */
+ public function testMultipleStorages(): void {
+ $a = new MemoryStorage();
+ $a->write('a_key', ['a_data_first']);
+ $b = new MemoryStorage();
+
+ // Add a conflicting key so that we can test the first value is returned.
+ $b->write('a_key', ['a_data_second']);
+ $b->write('b_key', ['b_data']);
+
+ // We test with a third storage as well since only two storages can be done
+ // via the constructor alone.
+ $c = new MemoryStorage();
+ $c->write('c_key', ['c_data']);
+
+ $storages = [$a, $b, $c];
+ $wrapped = RecipeConfigStorageWrapper::createStorageFromArray($storages);
+
+ $this->assertSame($a->read('a_key'), $wrapped->read('a_key'));
+ $this->assertNotEquals($b->read('a_key'), $wrapped->read('a_key'));
+ $this->assertSame($b->read('b_key'), $wrapped->read('b_key'));
+ $this->assertSame($c->read('c_key'), $wrapped->read('c_key'));
+ }
+
+ /**
+ * Validate that the first storage checks existence first.
+ */
+ public function testLeftSideExists(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('exists')->with('a_key')
+ ->willReturn(TRUE);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->never())->method('exists');
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertTrue($storage->exists('a_key'));
+ }
+
+ /**
+ * Validate that we fall back to the second storage.
+ */
+ public function testRightSideExists(): void {
+ [$a, $b] = $this->generateStorages(TRUE);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $storage->exists('a_key');
+ }
+
+ /**
+ * Validate FALSE when neither storage contains a key.
+ */
+ public function testNotExists(): void {
+ [$a, $b] = $this->generateStorages(FALSE);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertFalse($storage->exists('a_key'));
+ }
+
+ /**
+ * Validate that we read from storage A first.
+ */
+ public function testReadFromA(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $value = ['a_value'];
+ $a->expects($this->once())->method('read')->with('a_key')
+ ->willReturn($value);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->never())->method('read');
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertSame($value, $storage->read('a_key'));
+ }
+
+ /**
+ * Validate that we read from storage B second.
+ */
+ public function testReadFromB(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('read')->with('a_key')
+ ->willReturn(FALSE);
+ $b = $this->createMock(StorageInterface::class);
+ $value = ['a_value'];
+ $b->expects($this->once())->method('read')->with('a_key')
+ ->willReturn($value);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertSame($value, $storage->read('a_key'));
+ }
+
+ /**
+ * Validate when neither storage can read a value.
+ */
+ public function testReadFails(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('read')->with('a_key')
+ ->willReturn(FALSE);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->once())->method('read')->with('a_key')
+ ->willReturn(FALSE);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertFalse($storage->read('a_key'));
+ }
+
+ /**
+ * Test reading multiple values.
+ */
+ public function testReadMultiple(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+ ->willReturn(['a_key' => ['a_value']]);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+ ->willReturn(['b_key' => ['b_value']]);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertEquals([
+ 'a_key' => ['a_value'],
+ 'b_key' => ['b_value'],
+ ], $storage->readMultiple(['a_key', 'b_key']));
+ }
+
+ /**
+ * Test that storage A has precedence over storage B.
+ */
+ public function testReadMultipleStorageA(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+ ->willReturn(['a_key' => ['a_value']]);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+ ->willReturn(['a_key' => ['a_conflicting_value'], 'b_key' => ['b_value']]);
+
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertEquals([
+ 'a_key' => ['a_value'],
+ 'b_key' => ['b_value'],
+ ], $storage->readMultiple(['a_key', 'b_key']));
+ }
+
+ /**
+ * Test methods that are unsupported.
+ *
+ * @param string $method
+ * The method to call.
+ * @param array $args
+ * The arguments to pass to the method.
+ *
+ * @testWith ["write", "name", []]
+ * ["delete", "name"]
+ * ["rename", "old_name", "new_name"]
+ * ["deleteAll"]
+ */
+ public function testUnsupportedMethods(string $method, ...$args): void {
+ $this->expectException(\BadMethodCallException::class);
+ $storage = new RecipeConfigStorageWrapper(new NullStorage(), new NullStorage());
+ $storage->{$method}(...$args);
+ }
+
+ /**
+ * Test that we only use storage A's encode method.
+ */
+ public function testEncode(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $b = $this->createMock(StorageInterface::class);
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->expectException(\BadMethodCallException::class);
+ $storage->encode(['value']);
+ }
+
+ /**
+ * Test that we only use storage A's decode method.
+ */
+ public function testDecode(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $b = $this->createMock(StorageInterface::class);
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->expectException(\BadMethodCallException::class);
+ $storage->decode('value');
+ }
+
+ /**
+ * Test that list all merges values and makes them unique.
+ */
+ public function testListAll(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->method('listAll')->with('node.')
+ ->willReturn(['node.type']);
+ $b = $this->createMock(StorageInterface::class);
+ $b->method('listAll')->with('node.')
+ ->willReturn(['node.type', 'node.id']);
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertEquals([
+ 0 => 'node.type',
+ 2 => 'node.id',
+ ], $storage->listAll('node.'));
+ }
+
+ /**
+ * Test creating a collection passes the name through to the child storages.
+ */
+ public function testCreateCollection(): void {
+ $collection_name = 'collection';
+ $a = $this->createMock(StorageInterface::class);
+ $b = $this->createMock(StorageInterface::class);
+ /** @var \PHPUnit\Framework\MockObject\MockObject $mock */
+ foreach ([$a, $b] as $mock) {
+ $mock->expects($this->once())->method('createCollection')
+ ->with($collection_name)->willReturn(new NullStorage($collection_name));
+ }
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $new = $storage->createCollection($collection_name);
+ $this->assertInstanceOf(RecipeConfigStorageWrapper::class, $new);
+ $this->assertEquals($collection_name, $new->getCollectionName());
+ $this->assertNotEquals($storage, $new);
+ }
+
+ /**
+ * Test that we merge and return only unique collection names.
+ */
+ public function testGetAllCollectionNames(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('getAllCollectionNames')
+ ->willReturn(['collection_1', 'collection_2']);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->once())->method('getAllCollectionNames')
+ ->willReturn(['collection_3', 'collection_1', 'collection_2']);
+ $storage = new RecipeConfigStorageWrapper($a, $b);
+ $this->assertEquals([
+ 'collection_1',
+ 'collection_2',
+ 'collection_3',
+ ], $storage->getAllCollectionNames());
+ }
+
+ /**
+ * Test the collection name is stored properly.
+ */
+ public function testGetCollection(): void {
+ $a = $this->createMock(StorageInterface::class);
+ $b = $this->createMock(StorageInterface::class);
+ $storage = new RecipeConfigStorageWrapper($a, $b, 'collection');
+ $this->assertEquals('collection', $storage->getCollectionName());
+ }
+
+ /**
+ * Generate two storages where the second storage should return a value.
+ *
+ * @param bool $b_return
+ * The return value for storage $b's exist method.
+ *
+ * @return \Drupal\Core\Config\StorageInterface[]
+ * An array of two mocked storages.
+ */
+ private function generateStorages(bool $b_return): array {
+ $a = $this->createMock(StorageInterface::class);
+ $a->expects($this->once())->method('exists')->with('a_key')
+ ->willReturn(FALSE);
+ $b = $this->createMock(StorageInterface::class);
+ $b->expects($this->once())->method('exists')->with('a_key')
+ ->willReturn($b_return);
+ return [$a, $b];
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php
new file mode 100644
index 00000000000..35505fe1fe5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php
@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Recipe;
+
+use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
+use Drupal\Core\Test\TestDatabase;
+use Drupal\Tests\BrowserTestBase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Cookie\CookieJar;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Tests the quick-start command with recipes.
+ *
+ * These tests are run in a separate process because they load Drupal code via
+ * an include.
+ *
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ * @requires extension pdo_sqlite
+ *
+ * @group Command
+ * @group Recipe
+ */
+class RecipeQuickStartTest extends TestCase {
+
+ /**
+ * The PHP executable path.
+ *
+ * @var string
+ */
+ protected string $php;
+
+ /**
+ * A test database object.
+ *
+ * @var \Drupal\Core\Test\TestDatabase
+ */
+ protected TestDatabase $testDb;
+
+ /**
+ * The Drupal root directory.
+ *
+ * @var string
+ */
+ protected string $root;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $php_executable_finder = new PhpExecutableFinder();
+ $this->php = (string) $php_executable_finder->find();
+ $this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
+ if (!is_writable("{$this->root}/sites/simpletest")) {
+ $this->markTestSkipped('This test requires a writable sites/simpletest directory');
+ }
+ // Get a lock and a valid site path.
+ $this->testDb = new TestDatabase();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function tearDown(): void {
+ if ($this->testDb) {
+ $test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
+ if (file_exists($test_site_directory)) {
+ // @todo use the tear down command from
+ // https://www.drupal.org/project/drupal/issues/2926633
+ // Delete test site directory.
+ $this->fileUnmanagedDeleteRecursive($test_site_directory, BrowserTestBase::filePreDeleteCallback(...));
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Tests the quick-start command with a recipe.
+ */
+ public function testQuickStartRecipeCommand(): void {
+ $sqlite = (string) (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
+ if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
+ $this->markTestSkipped();
+ }
+
+ // Install a site using the standard recipe to ensure the one time login
+ // link generation works.
+
+ $install_command = [
+ $this->php,
+ 'core/scripts/drupal',
+ 'quick-start',
+ 'core/recipes/standard',
+ "--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
+ '--suppress-login',
+ ];
+ $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
+ $process->setTimeout(500);
+ $process->start();
+ $guzzle = new Client();
+ $port = FALSE;
+ $process->waitUntil(function ($type, $output) use (&$port) {
+ if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
+ $port = $match[1];
+ return TRUE;
+ }
+ });
+ // The progress bar uses STDERR to write messages.
+ $this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
+ // Ensure the command does not trigger any PHP deprecations.
+ $this->assertStringNotContainsStringIgnoringCase('deprecated', $process->getErrorOutput());
+ $this->assertNotFalse($port, "Web server running on port $port");
+
+ // Give the server a couple of seconds to be ready.
+ sleep(2);
+ $this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
+
+ // Generate a cookie so we can make a request against the installed site.
+ define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
+ chmod($this->testDb->getTestSitePath(), 0755);
+ $cookieJar = CookieJar::fromArray([
+ 'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
+ ], '127.0.0.1');
+
+ $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
+ $content = (string) $response->getBody();
+ $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
+ // Test content from Standard front page.
+ $this->assertStringContainsString('Congratulations and welcome to the Drupal community.', $content);
+
+ // Stop the web server.
+ $process->stop();
+ }
+
+ /**
+ * Deletes all files and directories in the specified path recursively.
+ *
+ * Note this method has no dependencies on Drupal core to ensure that the
+ * test site can be torn down even if something in the test site is broken.
+ *
+ * @param string $path
+ * A string containing either a URI or a file or directory path.
+ * @param callable $callback
+ * (optional) Callback function to run on each file prior to deleting it and
+ * on each directory prior to traversing it. For example, can be used to
+ * modify permissions.
+ *
+ * @return bool
+ * TRUE for success or if path does not exist, FALSE in the event of an
+ * error.
+ *
+ * @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
+ */
+ protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
+ if (isset($callback)) {
+ call_user_func($callback, $path);
+ }
+ if (is_dir($path)) {
+ $dir = dir($path);
+ assert($dir instanceof \Directory);
+ while (($entry = $dir->read()) !== FALSE) {
+ if ($entry == '.' || $entry == '..') {
+ continue;
+ }
+ $entry_path = $path . '/' . $entry;
+ $this->fileUnmanagedDeleteRecursive($entry_path, $callback);
+ }
+ $dir->close();
+
+ return rmdir($path);
+ }
+ return unlink($path);
+ }
+
+}
diff --git a/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml
new file mode 100644
index 00000000000..f126741702a
--- /dev/null
+++ b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml
@@ -0,0 +1,27 @@
+_meta:
+ version: '1.0'
+ entity_type: file
+ uuid: a6b79928-838f-44bd-a8f0-44c2fff9e4cc
+ default_langcode: en
+default:
+ uid:
+ -
+ target_id: 1
+ filename:
+ -
+ value: druplicon-different.png
+ uri:
+ -
+ value: 'public://2024-03/druplicon.png'
+ filemime:
+ -
+ value: text/plain
+ filesize:
+ -
+ value: 11
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711121742
diff --git a/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml
new file mode 100644
index 00000000000..df66730670d
--- /dev/null
+++ b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml
@@ -0,0 +1,24 @@
+_meta:
+ version: '1.0'
+ entity_type: block_content
+ uuid: d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf
+ bundle: basic
+ default_langcode: en
+default:
+ status:
+ -
+ value: true
+ info:
+ -
+ value: 'Useful Info'
+ reusable:
+ -
+ value: true
+ revision_translation_affected:
+ -
+ value: true
+ body:
+ -
+ value: "I'd love to put some useful info here."
+ format: plain_text
+ summary: ''
diff --git a/core/tests/fixtures/default_content/druplicon.png b/core/tests/fixtures/default_content/druplicon.png
new file mode 100644
index 00000000000..51bb1074d33
--- /dev/null
+++ b/core/tests/fixtures/default_content/druplicon.png
@@ -0,0 +1 @@
+Not a PNG.
diff --git a/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml
new file mode 100644
index 00000000000..f64d6f0eff5
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml
@@ -0,0 +1,27 @@
+_meta:
+ version: '1.0'
+ entity_type: file
+ uuid: 23a7f61f-1db3-407d-a6dd-eb4731995c9f
+ default_langcode: en
+default:
+ uid:
+ -
+ target_id: 1
+ filename:
+ -
+ value: druplicon-duplicate.png
+ uri:
+ -
+ value: 'public://2024-03/druplicon.png'
+ filemime:
+ -
+ value: image/png
+ filesize:
+ -
+ value: 3905
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml
new file mode 100644
index 00000000000..d07b0e63466
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml
@@ -0,0 +1,27 @@
+_meta:
+ version: '1.0'
+ entity_type: file
+ uuid: 2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8
+ default_langcode: en
+default:
+ uid:
+ -
+ target_id: 1
+ filename:
+ -
+ value: dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png
+ uri:
+ -
+ value: 'public://2024-03/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png'
+ filemime:
+ -
+ value: image/png
+ filesize:
+ -
+ value: 1233169
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711729897
diff --git a/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml
new file mode 100644
index 00000000000..86317492629
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml
@@ -0,0 +1,27 @@
+_meta:
+ version: '1.0'
+ entity_type: file
+ uuid: 7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d
+ default_langcode: en
+default:
+ uid:
+ -
+ target_id: 1
+ filename:
+ -
+ value: druplicon_copy.png
+ uri:
+ -
+ value: 'public://druplicon_copy.png'
+ filemime:
+ -
+ value: image/png
+ filesize:
+ -
+ value: 3905
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml
new file mode 100644
index 00000000000..7b2a0aa83a6
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml
@@ -0,0 +1,27 @@
+_meta:
+ version: '1.0'
+ entity_type: file
+ uuid: d8404562-efcc-40e3-869e-40132d53fe0b
+ default_langcode: en
+default:
+ uid:
+ -
+ target_id: 1
+ filename:
+ -
+ value: druplicon.png
+ uri:
+ -
+ value: 'public://2024-03/druplicon.png'
+ filemime:
+ -
+ value: image/png
+ filesize:
+ -
+ value: 3905
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/druplicon.png b/core/tests/fixtures/default_content/file/druplicon.png
new file mode 100644
index 00000000000..3b49a4ce78d
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/druplicon.png
Binary files differ
diff --git a/core/tests/fixtures/default_content/file/druplicon_copy.png b/core/tests/fixtures/default_content/file/druplicon_copy.png
new file mode 100644
index 00000000000..3b49a4ce78d
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/druplicon_copy.png
Binary files differ
diff --git a/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml
new file mode 100644
index 00000000000..d54c09c7693
--- /dev/null
+++ b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml
@@ -0,0 +1,38 @@
+_meta:
+ version: '1.0'
+ entity_type: media
+ uuid: 344b943c-b231-4d73-9669-0b0a2be12aa5
+ bundle: image
+ default_langcode: en
+ depends:
+ d8404562-efcc-40e3-869e-40132d53fe0b: file
+default:
+ revision_user:
+ -
+ target_id: 1
+ status:
+ -
+ value: true
+ uid:
+ -
+ target_id: 1
+ name:
+ -
+ value: druplicon.png
+ created:
+ -
+ value: 1711121695
+ revision_translation_affected:
+ -
+ value: true
+ path:
+ -
+ alias: ''
+ langcode: en
+ field_media_image:
+ -
+ entity: d8404562-efcc-40e3-869e-40132d53fe0b
+ alt: 'A Druplicon on a transparent background.'
+ title: ''
+ width: 88
+ height: 100
diff --git a/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml
new file mode 100644
index 00000000000..eecf8562c0c
--- /dev/null
+++ b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml
@@ -0,0 +1,38 @@
+_meta:
+ version: '1.0'
+ entity_type: menu_link_content
+ uuid: 3434bd5a-d2cd-4f26-bf79-a7f6b951a21b
+ bundle: menu_link_content
+ default_langcode: en
+ depends:
+ e1714f23-70c0-4493-8e92-af1901771921: node
+default:
+ enabled:
+ -
+ value: true
+ title:
+ -
+ value: 'Test Article'
+ menu_name:
+ -
+ value: main
+ link:
+ -
+ target_uuid: e1714f23-70c0-4493-8e92-af1901771921
+ title: ''
+ options: { }
+ external:
+ -
+ value: false
+ rediscover:
+ -
+ value: false
+ weight:
+ -
+ value: 0
+ expanded:
+ -
+ value: false
+ revision_translation_affected:
+ -
+ value: true
diff --git a/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml
new file mode 100644
index 00000000000..c29e6b14a49
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml
@@ -0,0 +1,83 @@
+_meta:
+ version: '1.0'
+ entity_type: node
+ uuid: 2d3581c3-92c7-4600-8991-a0d4b3741198
+ bundle: article
+ default_langcode: en
+ depends:
+ 94503467-be7f-406c-9795-fc25baa22203: user
+default:
+ revision_uid:
+ -
+ target_id: 1
+ status:
+ -
+ value: true
+ uid:
+ -
+ entity: 94503467-be7f-406c-9795-fc25baa22203
+ title:
+ -
+ value: 'Lost in translation'
+ created:
+ -
+ value: 1711976268
+ promote:
+ -
+ value: true
+ sticky:
+ -
+ value: false
+ path:
+ -
+ alias: ''
+ langcode: en
+ content_translation_source:
+ -
+ value: und
+ content_translation_outdated:
+ -
+ value: false
+ body:
+ -
+ value: "Here's the English version."
+ format: plain_text
+ summary: ''
+translations:
+ fr:
+ status:
+ -
+ value: true
+ uid:
+ -
+ target_id: 1
+ title:
+ -
+ value: 'Perdu en traduction'
+ created:
+ -
+ value: 1711976291
+ promote:
+ -
+ value: true
+ sticky:
+ -
+ value: false
+ revision_translation_affected:
+ -
+ value: true
+ path:
+ -
+ alias: ''
+ langcode: fr
+ content_translation_source:
+ -
+ value: en
+ content_translation_outdated:
+ -
+ value: false
+ body:
+ -
+ value: "Içi c'est la version français."
+ format: plain_text
+ summary: ''
diff --git a/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml
new file mode 100644
index 00000000000..023a1768304
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml
@@ -0,0 +1,44 @@
+_meta:
+ version: '1.0'
+ entity_type: node
+ uuid: 7f1dd75a-0be2-4d3b-be5d-9d1a868b9267
+ bundle: page
+ default_langcode: en
+ depends:
+ # This user does not actually exist; this lets us test that the node
+ # will be assigned to user 1 during the import.
+ e2b1b3fb-27ea-41ec-b70f-dbf2907fb658: user
+default:
+ revision_uid:
+ -
+ target_id: 1
+ status:
+ -
+ value: true
+ uid:
+ -
+ entity: e2b1b3fb-27ea-41ec-b70f-dbf2907fb658
+ title:
+ -
+ value: 'No Owner'
+ created:
+ -
+ value: 1711638565
+ promote:
+ -
+ value: false
+ sticky:
+ -
+ value: false
+ revision_translation_affected:
+ -
+ value: true
+ path:
+ -
+ alias: ''
+ langcode: en
+ body:
+ -
+ value: 'This page was authored by a non-existent user.'
+ format: plain_text
+ summary: ''
diff --git a/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml
new file mode 100644
index 00000000000..883cdfe15ad
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml
@@ -0,0 +1,46 @@
+_meta:
+ version: '1.0'
+ entity_type: node
+ uuid: e1714f23-70c0-4493-8e92-af1901771921
+ bundle: article
+ default_langcode: en
+ depends:
+ 94503467-be7f-406c-9795-fc25baa22203: user
+ 550f86ad-aa11-4047-953f-636d42889f85: taxonomy_term
+default:
+ revision_uid:
+ -
+ target_id: 1
+ status:
+ -
+ value: true
+ uid:
+ -
+ entity: 94503467-be7f-406c-9795-fc25baa22203
+ title:
+ -
+ value: 'Test Article'
+ created:
+ -
+ value: 1711476803
+ promote:
+ -
+ value: true
+ sticky:
+ -
+ value: false
+ revision_translation_affected:
+ -
+ value: true
+ path:
+ -
+ alias: /test-article
+ langcode: en
+ body:
+ -
+ value: 'Crikey it works!'
+ format: plain_text
+ summary: ''
+ field_tags:
+ -
+ entity: 550f86ad-aa11-4047-953f-636d42889f85
diff --git a/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml
new file mode 100644
index 00000000000..be025a848a3
--- /dev/null
+++ b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml
@@ -0,0 +1,31 @@
+_meta:
+ version: '1.0'
+ entity_type: taxonomy_term
+ uuid: 550f86ad-aa11-4047-953f-636d42889f85
+ bundle: tags
+ default_langcode: en
+default:
+ status:
+ -
+ value: true
+ name:
+ -
+ value: 'Default Content'
+ weight:
+ -
+ value: 0
+ parent:
+ -
+ target_id: 0
+ revision_translation_affected:
+ -
+ value: true
+ path:
+ -
+ alias: ''
+ langcode: en
+ field_serialized_stuff:
+ -
+ value:
+ - Hi
+ - there!
diff --git a/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml
new file mode 100644
index 00000000000..8f1632105eb
--- /dev/null
+++ b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml
@@ -0,0 +1,43 @@
+_meta:
+ version: '1.0'
+ entity_type: user
+ uuid: 94503467-be7f-406c-9795-fc25baa22203
+ default_langcode: en
+default:
+ preferred_langcode:
+ -
+ value: en
+ preferred_admin_langcode:
+ -
+ value: en
+ name:
+ -
+ value: 'Naomi Malone'
+ pass:
+ -
+ # cspell:disable
+ value: $2y$10$3GlpQmjbJ9raJNQ.JZmg/OVS7avJ7KPQxucunwovUtOvpKbe3k8lK
+ # cspell:enable
+ existing: ''
+ pre_hashed: false
+ mail:
+ -
+ value: author@example.com
+ timezone:
+ -
+ value: UTC
+ status:
+ -
+ value: true
+ created:
+ -
+ value: 1711125883
+ access:
+ -
+ value: 0
+ login:
+ -
+ value: 0
+ init:
+ -
+ value: author@example.com
diff --git a/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
new file mode 100644
index 00000000000..6d9589f9c3b
--- /dev/null
+++ b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
@@ -0,0 +1,6 @@
+name: 'Base theme and views'
+type: 'Testing'
+install:
+ - test_subsubtheme
+ - node
+ - views
diff --git a/core/tests/fixtures/recipes/config_actions/recipe.yml b/core/tests/fixtures/recipes/config_actions/recipe.yml
new file mode 100644
index 00000000000..4e16eeb67b7
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions/recipe.yml
@@ -0,0 +1,13 @@
+name: 'Config actions'
+type: 'Testing'
+install:
+ - config_test
+config:
+ actions:
+ config_test.dynamic.recipe:
+ ensure_exists:
+ label: 'Created by recipe'
+ setProtectedProperty: 'Set by recipe'
+ config_test.system:
+ simple_config_update:
+ foo: 'not bar'
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml
new file mode 100644
index 00000000000..369e09357e7
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with direct dependency present
+type: 'Testing'
+install:
+ - node
+config:
+ actions:
+ node.settings:
+ simple_config_update:
+ use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml
new file mode 100644
index 00000000000..f6ebd037e86
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with first level indirect dependency
+type: 'Testing'
+recipes:
+ - level_2
+config:
+ actions:
+ node.settings:
+ simple_config_update:
+ use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml
new file mode 100644
index 00000000000..ac2e9bdef7c
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with second level indirect dependency
+type: 'Testing'
+recipes:
+ - level_1
+config:
+ actions:
+ node.settings:
+ simple_config_update:
+ use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml
new file mode 100644
index 00000000000..3f902e7f419
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml
@@ -0,0 +1,4 @@
+name: First level sub recipe
+type: 'Testing'
+recipes:
+ - level_2
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml
new file mode 100644
index 00000000000..5e467f72ccf
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml
@@ -0,0 +1,4 @@
+name: Second level sub recipe
+type: 'Testing'
+install:
+ - node
diff --git a/core/tests/fixtures/recipes/config_from_module/recipe.yml b/core/tests/fixtures/recipes/config_from_module/recipe.yml
new file mode 100644
index 00000000000..f88aa486c1c
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module/recipe.yml
@@ -0,0 +1,9 @@
+name: 'Config from module'
+type: 'Testing'
+install:
+ - config_test
+config:
+ import:
+ config_test:
+ - config_test.dynamic.dotted.default
+ - config_test.dynamic.override
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
new file mode 100644
index 00000000000..ce5eb672c3d
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
@@ -0,0 +1,6 @@
+id: dotted.default
+label: 'Provided by recipe'
+weight: 0
+protected_property: Default
+# Intentionally commented out to verify default status behavior.
+# status: 1
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
new file mode 100644
index 00000000000..5aa1d937ae2
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
@@ -0,0 +1,2 @@
+foo: bar
+404: foo
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
new file mode 100644
index 00000000000..58771fd7b60
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
@@ -0,0 +1,11 @@
+name: 'Config from module and recipe'
+type: 'Testing'
+install:
+ - config_test
+ - shortcut
+ - system
+config:
+ import:
+ config_test: '*'
+ shortcut:
+ - shortcut.set.default
diff --git a/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml
new file mode 100644
index 00000000000..2bf0db3433c
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml
@@ -0,0 +1,19 @@
+name: Config rollback exception
+install:
+ - filter
+ - media
+config:
+ import:
+ filter: '*'
+ media: '*'
+ actions:
+ filter.format.plain_text:
+ setFilterConfig:
+ instance_id: media_embed
+ configuration: []
+ system.image:
+ # This will cause a validation error, which will trigger a rollback.
+ # The rollback should fail, since the Media module can't be uninstalled
+ # now that the plain_text format is using one of its filters.
+ simple_config_update:
+ non_existent_key: whatever!
diff --git a/core/tests/fixtures/recipes/config_wildcard/recipe.yml b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
new file mode 100644
index 00000000000..9c4e68af249
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
@@ -0,0 +1,10 @@
+name: 'Config wildcard'
+type: 'Testing'
+install:
+ - config_test
+ - shortcut
+ - system
+config:
+ import:
+ config_test: '*'
+ shortcut: ~
diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
new file mode 100644
index 00000000000..6cb95cbc42a
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
@@ -0,0 +1 @@
+use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
new file mode 100644
index 00000000000..adffa484d4b
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+name: 'Test content type'
+type: test
+description: 'Test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/tests/fixtures/recipes/install_node_with_config/recipe.yml b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
new file mode 100644
index 00000000000..e2fc243b529
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Install node with config'
+type: 'Content type'
+install:
+ - node
+ - drupal:text
diff --git a/core/tests/fixtures/recipes/install_two_modules/recipe.yml b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
new file mode 100644
index 00000000000..ee57ca146df
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Install two modules'
+type: 'Content type'
+install:
+ - node
+ - text
diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml
new file mode 100644
index 00000000000..6bfb62960d2
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml
@@ -0,0 +1,14 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ # Depend on the valid date format in order to ensure it is imported first,
+ # which means we can ensure it was rolled back when this date format raises
+ # a validation error.
+ - core.date_format.valid
+id: invalid
+# Null isn't a valid value for the label, so this should raise a validation
+# error.
+label: null
+locked: false
+pattern: 'F j, Y'
diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml
new file mode 100644
index 00000000000..7e446819831
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: { }
+id: valid
+label: 'Valid date format'
+locked: false
+pattern: 'F j, Y'
diff --git a/core/tests/fixtures/recipes/invalid_config/recipe.yml b/core/tests/fixtures/recipes/invalid_config/recipe.yml
new file mode 100644
index 00000000000..5f5cb88e788
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/recipe.yml
@@ -0,0 +1,2 @@
+name: 'Invalid config'
+type: 'Testing'
diff --git a/core/tests/fixtures/recipes/no_extensions/recipe.yml b/core/tests/fixtures/recipes/no_extensions/recipe.yml
new file mode 100644
index 00000000000..b7d3aeb4f09
--- /dev/null
+++ b/core/tests/fixtures/recipes/no_extensions/recipe.yml
@@ -0,0 +1,3 @@
+name: 'No extensions'
+description: 'A recipe description'
+type: 'Testing'
diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml
new file mode 100644
index 00000000000..c2cd6b73cd0
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml
@@ -0,0 +1,4 @@
+name: 'Recipe depending on an invalid recipe'
+type: 'Testing'
+recipes:
+ - invalid_config
diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml
new file mode 100644
index 00000000000..5e487e35a79
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Recipe depending on first installing modules, then a recipe with invalid config'
+type: 'Testing'
+recipes:
+ - install_two_modules
+ - invalid_config
diff --git a/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
new file mode 100644
index 00000000000..884f144dc77
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+name: 'Another test content type'
+type: another_test
+description: 'Another test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/tests/fixtures/recipes/recipe_include/recipe.yml b/core/tests/fixtures/recipes/recipe_include/recipe.yml
new file mode 100644
index 00000000000..a81aa075c09
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_include/recipe.yml
@@ -0,0 +1,6 @@
+name: 'Recipe include'
+type: 'Testing'
+recipes:
+ - install_node_with_config
+install:
+ - dblog
diff --git a/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
new file mode 100644
index 00000000000..550c3610a95
--- /dev/null
+++ b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Theme with module dependencies'
+type: 'Testing'
+install:
+ - test_theme_depending_on_modules
+ - test_module_required_by_theme
diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
new file mode 100644
index 00000000000..93a92f106d6
--- /dev/null
+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
@@ -0,0 +1,12 @@
+langcode: en
+status: true
+name: 'Test content type'
+type: test
+description: 'Test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
+dependencies:
+ config:
+ - core.date_format.non_existent
diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
new file mode 100644
index 00000000000..e90fca91578
--- /dev/null
+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
@@ -0,0 +1,2 @@
+name: 'Unmet config dependencies'
+type: 'Testing'