diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php index ec24dd0..7884369 100644 --- a/src/Integrations/Divi/DiviCompatibility.php +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi; use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; +use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use Throwable; @@ -64,8 +65,10 @@ final class DiviCompatibility 'Taxonomien layout_category, layout_tag (Bibliothek)' => static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'), // Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2). - 'Theme Builder: Funktionen et_theme_builder_* und et_()' => - static fn (): bool => (new DiviThemeBuilder())->missing() === [], + 'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter( + DiviThemeBuilder::manifest()->calls, + static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member()) + ) === [], 'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter( DiviThemeBuilder::postTypes(), static fn (string $type): bool => ! post_type_exists($type) diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php index 661cb54..b1c0e22 100644 --- a/src/Integrations/Divi/Release/DiviAdapters.php +++ b/src/Integrations/Divi/Release/DiviAdapters.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; +use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; defined('ABSPATH') || exit; @@ -20,6 +21,7 @@ final class DiviAdapters DiviPages::class, DiviLibraryStore::class, DiviRoles::class, + DiviThemeBuilder::class, DiviModules::class, DynamicContentMap::class, ]; diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index 11e7f31..eda601c 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\ThemeBuilder; +use LIT\MCP\Integrations\Divi\Release\Call; +use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\Manifest; +use LIT\MCP\Integrations\Divi\Release\Mirror; use RuntimeException; use Throwable; use WP_Error; @@ -11,26 +15,26 @@ use WP_Error; defined('ABSPATH') || exit; /** - * Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b). - * Reading uses Divi's own functions (theme-builder.php:378-521). Writing: + * Adapter "Theme Builder" (spec §3; E7, E15; decisions note F14–F23; fixtures stage3/stage3b). + * Reading uses Divi's own functions (theme-builder.php). Writing: * - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template() - * (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first, + * (theme-builder.php). Both wp_die() without edit_others_posts: the right is checked first, * and while they run every wp_die handler throws instead (guarded(), defense in depth); * - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from - * et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and - * store_template (theme-builder.php:1545-1583) – store_template on an existing template would also - * rewrite its default flag, title and onboarding meta. + * et_theme_builder_api_save() (admin-ajax only, not loaded in REST requests, F20) and + * et_theme_builder_store_template() – see the mirrors in manifest(); store_template on an existing + * template would also rewrite its default flag, title and onboarding meta. * Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts(). * Not final so abilities can be tested with a mock. */ -class DiviThemeBuilder +class DiviThemeBuilder implements DiviAdapter { public const TEMPLATE = 'et_template'; /** Post type of the Theme Builder posts (live, draft, library) that list the templates. */ public const THEME_BUILDER = 'et_theme_builder'; - /** Area => layout post type (theme-builder.php:7-23). */ + /** Area => layout post type (theme-builder.php). */ public const LAYOUT_TYPES = [ 'header' => 'et_header_layout', 'body' => 'et_body_layout', @@ -44,18 +48,6 @@ class DiviThemeBuilder public const UNUSED = '_et_theme_builder_marked_as_unused'; public const BACKUP_OPTION = 'et_tb_templates_backup_'; - /** Divi functions this adapter calls; DiviCompatibility reports missing ones. */ - public const FUNCTIONS = [ - 'et_theme_builder_get_theme_builder_post_id', - 'et_theme_builder_get_theme_builder_template_ids', - 'et_theme_builder_get_template', - 'et_theme_builder_insert_layout', - 'et_theme_builder_store_template', - 'et_theme_builder_get_flat_template_settings_options', - 'et_theme_builder_clear_wp_cache', - 'et_', - ]; - private const DIE_FILTERS = [ 'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler', 'wp_die_xml_handler', 'wp_die_xmlrpc_handler', @@ -83,10 +75,53 @@ class DiviThemeBuilder return is_string($area) ? $area : null; } - /** @return list */ - public function missing(): array + public static function manifest(): Manifest { - return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name))); + $dir = 'includes/builder/frontend-builder/theme-builder/'; + + return new Manifest( + Manifest::THEME_BUILDER, + calls: [ + new Call('et_theme_builder_get_theme_builder_post_id', 2), + new Call('et_theme_builder_get_theme_builder_template_ids', 2), + new Call('et_theme_builder_get_template', 1), + new Call('et_theme_builder_insert_layout', 1), + new Call('et_theme_builder_store_template', 3), + new Call('et_theme_builder_get_flat_template_settings_options', 0), + new Call('et_theme_builder_clear_wp_cache', 1), + // store_template needs Divi's et_() helper (E15). + new Call('et_'), + new Call('ET_Core_PageResource::remove_static_resources', 3), + ], + postTypes: self::postTypes(), + keys: [ + self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION, + ], + mirrors: [ + new Mirror( + 'tb-api-save', + $dir . 'api.php', + 'et_theme_builder_api_save', + null, + 'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und ' + . '„ungenutzt“-Markierung' + ), + new Mirror( + 'tb-store-template', + $dir . 'theme-builder.php', + 'et_theme_builder_store_template', + null, + 'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage' + ), + new Mirror( + 'tb-load-setting-options', + $dir . 'theme-builder.php', + 'et_theme_builder_load_template_setting_options', + null, + 'Zerlegen der Bedingungs-IDs (TemplateConditions)' + ), + ] + ); } /** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */ @@ -275,7 +310,7 @@ class DiviThemeBuilder } /** - * Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one + * Replaces both condition lists like store_template (theme-builder.php): delete, then one * row per unique condition. * * @param list $useOn @@ -338,7 +373,7 @@ class DiviThemeBuilder return $flat; } - /** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */ + /** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php). */ public function clearCaches(): void { self::clearSiteCaches(); @@ -360,7 +395,7 @@ class DiviThemeBuilder /** * Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user - * holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php:1464, 1644). + * holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php). * * @throws RuntimeException */ @@ -369,7 +404,7 @@ class DiviThemeBuilder if (! current_user_can('edit_others_posts')) { throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()'); } - $missing = array_values(array_intersect($functions, $this->missing())); + $missing = array_values(array_filter($functions, static fn (string $name): bool => ! function_exists($name))); if ($missing !== []) { throw new RuntimeException('Divi function missing: ' . implode(', ', $missing)); } diff --git a/src/Integrations/Divi/ThemeBuilder/Template.php b/src/Integrations/Divi/ThemeBuilder/Template.php index 13cdfbf..025932f 100644 --- a/src/Integrations/Divi/ThemeBuilder/Template.php +++ b/src/Integrations/Divi/ThemeBuilder/Template.php @@ -9,7 +9,7 @@ use LIT\MCP\Abilities\Rev; defined('ABSPATH') || exit; /** - * A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521; + * A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php; * fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's * "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10). * rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls. @@ -238,7 +238,7 @@ final class Template } /** - * The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:, + * The page families a condition covers (Divi's IDs, theme-builder.php): singular:post_type:, * archive:taxonomy:, archive:post_type:, archive:user, archive:date; ":all" and posts * in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive. * diff --git a/src/Integrations/Divi/ThemeBuilder/TemplateConditions.php b/src/Integrations/Divi/ThemeBuilder/TemplateConditions.php index 5e5b96d..d4bd87d 100644 --- a/src/Integrations/Divi/ThemeBuilder/TemplateConditions.php +++ b/src/Integrations/Divi/ThemeBuilder/TemplateConditions.php @@ -13,11 +13,12 @@ defined('ABSPATH') || exit; /** * Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list * (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry - * plus one object segment, split the way Divi reads it back (et_theme_builder_load_template_setting_options(): - * the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post - * of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with - * assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for - * missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here. + * plus one object segment, split the way Divi reads it back (mirror tb-load-setting-options in + * DiviThemeBuilder::manifest(): the last segment is the object). The object must be readable by the user + * (ruling P10: PostIdParser): a post of that type with read_post (not trashed, no auto-draft/attachment status), + * a term of that taxonomy with assign_term, a user only with list_users (ruling Q10, stricter than Divi), an + * existing role. Refusals for missing and unreadable objects read the same and never name an object's title + * (P12). Nothing is written here. * Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with * other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's * Theme Builder (review M-6). diff --git a/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php index 5fb5a40..cc3a9d5 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php @@ -26,7 +26,6 @@ final class DiviBoundaryTest extends TestCase private const LEGACY = [ 'Integrations/Divi/Design/DesignSystemReader.php', 'Integrations/Divi/DiviCompatibility.php', - 'Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php', 'Integrations/Media/MediaUsageFinder.php', ]; diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php index 1a3bfc6..38a4270 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php @@ -7,6 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder; use Brain\Monkey\Actions; use Brain\Monkey\Filters; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Release\Call; +use LIT\MCP\Integrations\Divi\Release\DiviAdapters; +use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure; use LIT\MCP\Tests\Unit\TestCase; @@ -17,6 +20,23 @@ use WP_Post; final class DiviThemeBuilderTest extends TestCase { + public function testIsTheRegisteredThemeBuilderAdapterWithItsMirrors(): void + { + $manifest = DiviThemeBuilder::manifest(); + + self::assertContains(DiviThemeBuilder::class, DiviAdapters::CLASSES); + self::assertSame(Manifest::THEME_BUILDER, $manifest->area); + self::assertSame(DiviThemeBuilder::postTypes(), $manifest->postTypes); + self::assertSame( + ['tb-api-save', 'tb-store-template', 'tb-load-setting-options'], + array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors) + ); + self::assertContains('et_theme_builder_store_template()', array_map( + static fn (Call $call): string => $call->label(), + $manifest->calls + )); + } + public function testAreaOfTheLayoutTypes(): void { self::assertSame('header', DiviThemeBuilder::areaOf('et_header_layout')); @@ -383,26 +403,28 @@ final class DiviThemeBuilderTest extends TestCase (new DiviThemeBuilder())->insertLayout('sidebar', 'Seite'); } - /** E15: a Divi function that moved is reported, never called (no fatal error mid-write). */ + /** + * E15: a Divi function that moved is reported, never called (no fatal error mid-write). Own process: + * no other test may have stubbed these names yet, or function_exists() would report them as present + * for the rest of the run (Brain Monkey defines a stubbed function process-wide). + * + * @runInSeparateProcess + */ public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void { Functions\when('current_user_can')->justReturn(true); - Functions\expect('et_theme_builder_insert_layout')->never(); - Functions\expect('et_theme_builder_store_template')->never(); - $store = Mockery::mock(DiviThemeBuilder::class)->makePartial(); - $store->allows('missing')->andReturn(['et_theme_builder_insert_layout', 'et_']); foreach ([ - static fn () => $store->insertLayout('body', 'Person – Inhalt'), - static fn () => $store->storeTemplate(1064, [ + static fn (DiviThemeBuilder $store) => $store->insertLayout('body', 'Person – Inhalt'), + static fn (DiviThemeBuilder $store) => $store->storeTemplate(1064, [ 'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], ]), ] as $call) { try { - $call(); + $call(new DiviThemeBuilder()); self::fail('Divi was called although it is missing'); } catch (RuntimeException $e) { - self::assertStringContainsString('missing', $e->getMessage()); + self::assertStringContainsString('Divi function missing', $e->getMessage()); } } }