feat: manifest checks, Divi compatibility from manifests, reference fingerprints

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:37:51 +02:00
co-authored by Claude Opus 5.5
parent 1a215fd7bf
commit 6b93df94c3
15 changed files with 1295 additions and 97 deletions
+3 -4
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
defined('ABSPATH') || exit;
@@ -21,10 +22,8 @@ class EnvironmentProbe
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
public function components(): array
{
$theme = wp_get_theme();
$isDivi = $theme->get_template() === 'Divi';
$parent = $theme->parent();
$diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null;
$diviVersion = DiviEnvironment::version();
$isDivi = $diviVersion !== null;
$tecActive = class_exists('Tribe__Events__Main');
+9 -51
View File
@@ -4,21 +4,14 @@ declare(strict_types=1);
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;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
defined('ABSPATH') || exit;
/**
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
* functions, the library post type and taxonomies, the instance block type. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports
* "unsupported".
* Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls
* with their argument count, constants, registrations and the fingerprints of the mirrors. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports "unsupported".
*/
final class DiviCompatibility
{
@@ -30,49 +23,14 @@ final class DiviCompatibility
/** @return list<string> Labels of the checks that failed or threw. */
public function problems(): array
{
$problems = [];
foreach ($this->checks ?? self::defaults() as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$problems[] = (string) $label;
}
}
return $problems;
return ManifestCheck::failed($this->checks ?? self::defaults());
}
/** @return array<string, callable(): bool> */
public static function defaults(): array
public static function defaults(?ManifestCheck $check = null): array
{
return [
'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
'DynamicContentOptions (Dynamic Content)' =>
static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
'Post-Typ et_pb_layout (Bibliothek)' =>
static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
'GlobalLayout::is_global_layout_template (globale Module)' =>
static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
'Block-Typ divi/global-layout (Instanzen globaler Module)' =>
static fn (): bool => DiviModules::knowsBlock(GlobalInstance::BLOCK),
'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 => 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)
) === [],
];
$check ??= new ManifestCheck();
return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The installed Divi theme: version (the parent's for a child theme) and directory; null when Divi is not the
* template.
*/
final class DiviEnvironment
{
public const TEMPLATE = 'Divi';
public static function version(): ?string
{
$theme = wp_get_theme();
if ($theme->get_template() !== self::TEMPLATE) {
return null;
}
$parent = $theme->parent();
return (string) ($parent ? $parent->get('Version') : $theme->get('Version'));
}
public static function themeDir(): ?string
{
return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
* wp lit-mcp divi-check --update-reference.
*/
final class DiviReference
{
public const FILE = __DIR__ . '/fingerprints.json';
/** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
public static function expected(?string $file = null): array
{
$file ??= self::FILE;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
$raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
$data = is_string($raw) ? json_decode($raw, true) : null;
if (! is_array($data)) {
return [];
}
$expected = [];
foreach ($data as $id => $hash) {
if (is_string($id) && is_string($hash)) {
$expected[$id] = $hash;
}
}
ksort($expected);
return $expected;
}
/**
* @param array<string, string> $hashes Mirror ID => sha256.
* @throws RuntimeException
*/
public static function writeExpected(array $hashes, ?string $file = null): void
{
ksort($hashes);
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP, no WordPress.
$json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) {
throw new RuntimeException('fingerprints.json not written');
}
}
/** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
public static function sourceDir(): ?string
{
$dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference';
return is_dir($dir) ? $dir : null;
}
public static function readSource(string $id, string $dir): ?string
{
$file = $dir . '/' . $id . '.txt';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local dev fixture.
$source = is_file($file) ? file_get_contents($file) : false;
return is_string($source) ? $source : null;
}
/** @throws RuntimeException */
public static function writeSource(string $id, string $source, string $dir): void
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) {
throw new RuntimeException(sprintf('reference source %s not written', $id));
}
}
/**
* Fingerprints of every mirror in the installed Divi.
*
* @return array<string, SourceFingerprint>
* @throws RuntimeException When a mirrored function cannot be read.
*/
public static function current(string $themeDir): array
{
$prints = [];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir . '/' . $mirror->file;
$prints[$mirror->id] = SourceFingerprint::of($file, $mirror->function, $mirror->class);
}
return $prints;
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
defined('ABSPATH') || exit;
/**
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
*/
final class ManifestCheck
{
/** @var array<string, Manifest> */
private array $manifests;
/** @var Closure(Mirror): ?string */
private Closure $current;
/** @var Closure(string): bool */
private Closure $knowsBlock;
/**
* @param array<string, Manifest>|null $manifests null = DiviAdapters::manifests().
* @param array<string, string>|null $expected Mirror ID => sha256; null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory.
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock().
*/
public function __construct(
?array $manifests = null,
private ?array $expected = null,
?Closure $current = null,
?Closure $knowsBlock = null
) {
$this->manifests = $manifests ?? DiviAdapters::manifests();
$this->current = $current ?? self::installed(...);
$this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...);
}
/** @return array<string, Closure(): bool> */
public function codeChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
}
foreach ($manifest->constants as $constant) {
$checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function mirrorChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
$label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
$checks[$label] = function () use ($mirror): bool {
$expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
};
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function registrationChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->postTypes as $type) {
$checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type);
}
foreach ($manifest->taxonomies as $taxonomy) {
$checks[$manifest->area . ': Taxonomie ' . $taxonomy] =
static fn (): bool => taxonomy_exists($taxonomy);
}
foreach ($manifest->blockTypes as $block) {
$checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
}
}
return $checks;
}
/**
* @param array<string, callable(): bool> $checks
* @return list<string> Labels of the checks that failed or threw.
*/
public static function failed(array $checks): array
{
$failed = [];
foreach ($checks as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$failed[] = (string) $label;
}
}
return $failed;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool
{
$class = $call->className();
if ($class === null) {
if (! function_exists($call->member())) {
return false;
}
$reflection = new ReflectionFunction($call->member());
} else {
if (! method_exists($class, $call->member())) {
return false;
}
$reflection = new ReflectionMethod($class, $call->member());
}
return $call->args === null
|| ($reflection->getNumberOfRequiredParameters() <= $call->args
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
return $dir === null
? null
: SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
}
}
@@ -0,0 +1,7 @@
{
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
"vb-sync-flags": "f8e85421197b0f823219dad6081af7e47f9b4e388c45e162e9188f892766fde9"
}
@@ -0,0 +1,69 @@
function et_pb_submit_layout( $args ) {
/**
* Filters the layout data passed to {@see et_pb_submit_layout()}.
*
* @since 3.0.99
*
* @param string[] $args See {@see et_pb_submit_layout()} for array structure definition.
*/
$args = apply_filters( 'et_pb_submit_layout_args', $args );
if ( empty( $args ) ) {
return '';
}
$layout_cats_processed = array();
$layout_tags_processed = array();
if ( '' !== $args['layout_selected_cats'] ) {
$layout_cats_array = explode( ',', $args['layout_selected_cats'] );
$layout_cats_processed = array_map( 'intval', $layout_cats_array );
}
if ( '' !== $args['layout_selected_tags'] ) {
$layout_tags_array = explode( ',', $args['layout_selected_tags'] );
$layout_tags_processed = array_map( 'intval', $layout_tags_array );
}
$meta = array();
if ( 'row' === $args['layout_type'] && '0' !== $args['columns_layout'] ) {
$meta = array_merge( $meta, array( '_et_pb_row_layout' => $args['columns_layout'] ) );
}
if ( 'module' === $args['layout_type'] ) {
$meta = array_merge( $meta, array( '_et_pb_module_type' => $args['module_type'] ) );
// save unsynced options for global modules. Always empty for new modules.
if ( 'global' === $args['layout_scope'] ) {
$meta = array_merge( $meta, array( '_et_pb_excluded_global_options' => wp_json_encode( array() ) ) );
}
}
// et_layouts_built_for_post_type.
$meta = array_merge(
$meta,
array(
'_et_pb_built_for_post_type' => $args['built_for_post_type'],
'_et_pb_template_type' => $args['template_type'] ?? '',
)
);
$tax_input = array(
'scope' => $args['layout_scope'],
'layout_type' => $args['layout_type'],
'module_width' => $args['module_width'],
'layout_category' => $layout_cats_processed,
'layout_tag' => $layout_tags_processed,
'layout_location' => et_()->array_get_sanitized( $args, 'layout_location', 'local' ),
);
$post_date = isset( $args['post_date'] ) && ! empty( $args['post_date'] ) ? $args['post_date'] : null;
$new_layout_id = et_pb_create_layout( $args['layout_name'], $args['layout_content'], $meta, $tax_input, $args['layout_new_cat'], $args['layout_new_tag'], 'publish', $post_date );
$new_post_data['post_id'] = (int) $new_layout_id;
$new_post_data['edit_link'] = esc_url_raw( get_edit_post_link( $new_layout_id ) );
$json_post_data = wp_json_encode( $new_post_data );
return $json_post_data;
}
@@ -0,0 +1,113 @@
function et_theme_builder_api_save() {
et_builder_security_check( 'theme_builder', 'edit_others_posts', 'et_theme_builder_api_save', 'nonce' );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce is done in `et_builder_security_check`.
$_ = et_();
$live = '1' === $_->array_get( $_POST, 'live', '0' );
$first_request = '1' === $_->array_get( $_POST, 'first_request', '1' );
$last_request = '1' === $_->array_get( $_POST, 'last_request', '1' );
$templates = wp_unslash( $_->array_get( $_POST, 'templates', array() ) );
$processed_templates = wp_unslash( $_->array_get( $_POST, 'processed_templates', array() ) );
$library_tb_id = (int) $_->array_get( $_POST, 'library_theme_builder_id', 0 );
$library_item_id = (int) $_->array_get( $_POST, 'library_item_id', 0 );
$theme_builder_id = $library_tb_id ? $library_tb_id : et_theme_builder_get_theme_builder_post_id( $live, true );
$has_default = '1' === $_->array_get( $_POST, 'hasDefault', '0' );
$updated_ids = array();
// phpcs:enable
// Remove this action as it not necessary when we're saving entire TB.
// save_post_cb is a heavy operation and significanlty slows down the saving of TB.
// We remove static page resources after TB save below in this function.
remove_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 );
$templates_to_process = array();
// Populate the templates.
foreach ( $templates as $index => $template ) {
$templates_to_process[ $_->array_get( $template, 'id', 'unsaved_' . $index ) ] = $template;
}
$affected_templates = array();
// Update or insert templates.
foreach ( $templates_to_process as $template ) {
$raw_post_id = $_->array_get( $template, 'id', 0 );
$post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0;
$new_post_id = et_theme_builder_store_template( $theme_builder_id, $template, ! $has_default );
if ( ! $new_post_id ) {
continue;
}
$is_default = get_post_meta( $new_post_id, '_et_default', true ) === '1';
if ( $is_default ) {
$has_default = true;
}
// Add template ID into $affected_templates for later use
// to Add mapping template ID to theme builder ID
// and delete existing template mapping.
$affected_templates[] = array(
'raw' => $raw_post_id,
'normalized' => $post_id,
'new_post_id' => $new_post_id,
);
}
foreach ( $affected_templates as $template_pair ) {
if ( $template_pair['normalized'] !== $template_pair['new_post_id'] ) {
$updated_ids[ $template_pair['raw'] ] = $template_pair['new_post_id'];
}
}
if ( $last_request ) {
$existing_templates = get_post_meta( $theme_builder_id, '_et_template', false );
if ( $existing_templates ) {
// Store existing template mapping as backup to avoid data lost
// when user interrupting the saving process before completed.
update_option( 'et_tb_templates_backup_' . $theme_builder_id, $existing_templates );
}
// Delete existing template mapping.
delete_post_meta( $theme_builder_id, '_et_template' );
$processed_templates = array_merge( $processed_templates, $affected_templates );
// Insert new template mapping.
foreach ( $processed_templates as $template_pair ) {
add_post_meta( $theme_builder_id, '_et_template', $template_pair['new_post_id'] );
}
// Delete existing template mapping backup.
delete_option( 'et_tb_templates_backup_' . $theme_builder_id );
if ( $live ) {
et_theme_builder_trash_draft_and_unused_posts();
}
et_theme_builder_clear_wp_cache( 'all' );
// Clear all CSS cache files when Theme Builder templates are saved.
// Theme builder CSS files include layout IDs in the filename (e.g., et-core-unified-tb-285804-tb-285806-279784.min),
// so we need to clear all CSS files to ensure files with these layout IDs are removed.
ET_Core_PageResource::remove_static_resources( 'all', 'all' );
}
// Edit Template and Edit Preset: Save the templates into local library.
if ( $library_tb_id && $library_item_id ) {
et_theme_builder_update_library_item( $library_item_id, $templates );
}
// Add this action back.
add_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 );
wp_send_json_success(
array(
'updatedTemplateIds' => (object) $updated_ids,
'processedTemplatesData' => (object) $affected_templates,
'hasDefault' => $has_default ? '1' : '0',
)
);
}
@@ -0,0 +1,36 @@
function et_theme_builder_load_template_setting_options( $setting_ids ) {
$flat_parent_settings = et_theme_builder_get_flat_template_settings_options();
$groups = array();
foreach ( $setting_ids as $setting_id ) {
$parent_id = explode( ET_THEME_BUILDER_SETTING_SEPARATOR, $setting_id );
$entity_id = implode( '', array_slice( $parent_id, -1 ) );
$parent_id = array_slice( $parent_id, 0, -1 );
$parent_id = implode( ET_THEME_BUILDER_SETTING_SEPARATOR, $parent_id ) . ET_THEME_BUILDER_SETTING_SEPARATOR;
if ( ! isset( $flat_parent_settings[ $parent_id ] ) ) {
// Top-level, invalid or unknown setting.
continue;
}
if ( ! isset( $groups[ $parent_id ] ) ) {
$groups[ $parent_id ] = array(
'parent' => $flat_parent_settings[ $parent_id ],
'settings' => array(),
);
}
$groups[ $parent_id ]['settings'][ $setting_id ] = $entity_id;
}
$settings = array();
foreach ( $groups as $parent_id => $group ) {
$settings = array_merge(
$settings,
et_theme_builder_get_template_setting_child_options( $group['parent'], $group['settings'] )
);
}
return $settings;
}
@@ -0,0 +1,124 @@
function et_theme_builder_store_template( $theme_builder_id, $template, $allow_default ) {
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_die();
}
$_ = et_();
$raw_post_id = $_->array_get( $template, 'id', 0 );
$post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0;
$title = sanitize_text_field( $_->array_get( $template, 'title', '' ) );
$default = $allow_default && '1' === $_->array_get( $template, 'default', '1' );
$enabled = '1' === $_->array_get( $template, 'enabled', '1' );
$header_id = (int) $_->array_get( $template, 'layouts.header.id', 0 );
$header_enabled = (bool) $_->array_get( $template, 'layouts.header.enabled', true );
$body_id = (int) $_->array_get( $template, 'layouts.body.id', 0 );
$body_enabled = (bool) $_->array_get( $template, 'layouts.body.enabled', true );
$footer_id = (int) $_->array_get( $template, 'layouts.footer.id', 0 );
$footer_enabled = (bool) $_->array_get( $template, 'layouts.footer.enabled', true );
$use_on = array_map( 'sanitize_text_field', $_->array_get( $template, 'use_on', array() ) );
$exclude_from = array_map( 'sanitize_text_field', $_->array_get( $template, 'exclude_from', array() ) );
$exists = $post_id > 0 && ET_THEME_BUILDER_TEMPLATE_POST_TYPE === get_post_type( $post_id ) && 'publish' === get_post_status( $post_id );
$autogenerated_title = '1' === $_->array_get( $template, 'autogenerated_title', '1' );
$onboarding = (bool) $_->array_get( $template, 'onboarding', '0' );
if ( ET_THEME_BUILDER_HEADER_LAYOUT_POST_TYPE !== get_post_type( $header_id ) || 'publish' !== get_post_status( $header_id ) ) {
$header_id = 0;
}
if ( ET_THEME_BUILDER_BODY_LAYOUT_POST_TYPE !== get_post_type( $body_id ) || 'publish' !== get_post_status( $body_id ) ) {
$body_id = 0;
}
if ( ET_THEME_BUILDER_FOOTER_LAYOUT_POST_TYPE !== get_post_type( $footer_id ) || 'publish' !== get_post_status( $footer_id ) ) {
$footer_id = 0;
}
if ( $exists ) {
// Preform update only when needed.
if ( get_post_field( 'post_title', $post_id, 'raw' ) !== $title ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $title,
)
);
// Update layout title for each template.
et_theme_builder_update_layout_title( $template );
}
update_post_meta( $post_id, '_et_onboarding_created', '0' );
} else {
$post_id = wp_insert_post(
array(
'post_type' => ET_THEME_BUILDER_TEMPLATE_POST_TYPE,
'post_status' => 'publish',
'post_title' => $title,
)
);
if ( $onboarding ) {
update_post_meta( $post_id, '_et_onboarding_created', '1' );
}
}
if ( 0 === $post_id || is_wp_error( $post_id ) ) {
return false;
}
$metas = array(
'_et_autogenerated_title' => $autogenerated_title ? '1' : '0',
'_et_default' => $default ? '1' : '0',
'_et_enabled' => $enabled ? '1' : '0',
'_et_header_layout_id' => $header_id,
'_et_header_layout_enabled' => $header_enabled ? '1' : '0',
'_et_body_layout_id' => $body_id,
'_et_body_layout_enabled' => $body_enabled ? '1' : '0',
'_et_footer_layout_id' => $footer_id,
'_et_footer_layout_enabled' => $footer_enabled ? '1' : '0',
);
foreach ( $metas as $key => $value ) {
if ( strval( $value ) === strval( get_post_meta( $post_id, $key, true ) ) ) {
continue;
}
update_post_meta( $post_id, $key, $value );
}
if ( $onboarding ) {
$onboarding_use_on = get_post_meta( $post_id, '_et_use_on' );
$onboarding_exclude_from = get_post_meta( $post_id, '_et_exclude_from' );
foreach ( $onboarding_use_on as $condition ) {
add_post_meta( $post_id, '_et_old_use_on', $condition );
}
foreach ( $onboarding_exclude_from as $condition ) {
add_post_meta( $post_id, '_et_old_exclude_from', $condition );
}
}
// Handle _et_use_on meta.
delete_post_meta( $post_id, '_et_use_on' );
if ( $use_on ) {
$use_on_unique = array_unique( $use_on );
foreach ( $use_on_unique as $condition ) {
add_post_meta( $post_id, '_et_use_on', $condition );
}
}
// Handle _et_exclude_from meta.
delete_post_meta( $post_id, '_et_exclude_from' );
if ( $exclude_from ) {
$exclude_from_unique = array_unique( $exclude_from );
foreach ( $exclude_from_unique as $condition ) {
add_post_meta( $post_id, '_et_exclude_from', $condition );
}
}
return $post_id;
}
@@ -0,0 +1,444 @@
function update( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
$post_status = $request->get_param( 'post_status' );
$content = $request->get_param( 'content' );
$preferences = $request->get_param( 'preferences' );
$page_settings_by_layout = $request->get_param( 'pageSettingsByLayout' );
$sync_type = $request->get_param( 'syncType' );
$options = $request->get_param( 'options' );
$workspace = $request->get_param( 'workspace' );
$prime_cache_url = $request->get_param( 'prime_cache_url' );
$main_loop_type = $request->get_param( 'mainLoopType' );
$main_loop_type = is_string( $main_loop_type ) ? $main_loop_type : 'singular';
$loop_settings_data = $request->get_param( 'mainLoopSettingsData' );
$layout_post_ids = $request->get_param( 'layout_post_ids' );
$layout_post_ids = is_array( $layout_post_ids ) ? $layout_post_ids : [];
$layout_post_ids = self::sanitize_layout_post_ids( $layout_post_ids );
$page_settings_by_layout = is_array( $page_settings_by_layout ) ? $page_settings_by_layout : [];
$layout_post_ids = self::_validate_and_normalize_layout_post_ids( $layout_post_ids, $post_id, $main_loop_type );
if ( is_wp_error( $layout_post_ids ) ) {
return self::response_error(
$layout_post_ids->get_error_code(),
$layout_post_ids->get_error_message(),
$layout_post_ids->get_error_data()
);
}
foreach ( $page_settings_by_layout as $layout => $layout_page_settings ) {
if ( ! is_array( $layout_page_settings ) || ! isset( $layout_page_settings['customCss'] ) ) {
continue;
}
$page_settings_by_layout[ $layout ]['customCss'] = str_replace( '\\', '\\\\', $layout_page_settings['customCss'] );
}
$post_content = isset( $content['post_content'] ) ? $content['post_content'] : '';
$header_content = isset( $content['header'] ) ? $content['header'] : '';
$body_content = isset( $content['body'] ) ? $content['body'] : '';
$footer_content = isset( $content['footer'] ) ? $content['footer'] : '';
$has_post_content_payload = '' !== $post_content;
$post_content_post_id = isset( $layout_post_ids['postContent'] ) ? absint( $layout_post_ids['postContent'] ) : $post_id;
$header_post_id = isset( $layout_post_ids['header'] ) ? absint( $layout_post_ids['header'] ) : 0;
$body_post_id = isset( $layout_post_ids['body'] ) ? absint( $layout_post_ids['body'] ) : 0;
$footer_post_id = isset( $layout_post_ids['footer'] ) ? absint( $layout_post_ids['footer'] ) : 0;
$post_content_page_settings = isset( $page_settings_by_layout['postContent'] ) && is_array( $page_settings_by_layout['postContent'] )
? $page_settings_by_layout['postContent']
: [];
// Guardrail: never allow empty placeholder-only payloads to overwrite Theme Builder layouts.
$header_has_content = self::_has_meaningful_layout_content( $header_content );
$body_has_content = self::_has_meaningful_layout_content( $body_content );
$footer_has_content = self::_has_meaningful_layout_content( $footer_content );
if ( ! $header_has_content ) {
$header_post_id = 0;
}
if ( ! $body_has_content ) {
$body_post_id = 0;
}
if ( ! $footer_has_content ) {
$footer_post_id = 0;
}
// Extract off-canvas data and make it available for hooks.
$off_canvas_data = $request->get_param( 'off_canvas_data' );
if ( $off_canvas_data ) {
// Normalize to array format - decode JSON string if needed.
$normalized_canvas_data = self::_normalize_off_canvas_data( $off_canvas_data );
if ( $normalized_canvas_data ) {
// Store off-canvas data globally so hooks can access it.
$GLOBALS['divi_off_canvas_data'] = $normalized_canvas_data;
}
}
// Check if there is an autosave that is newer.
$post_author = get_current_user_id();
// Store one autosave per author. If there is already an autosave, overwrite it.
$autosave = wp_get_post_autosave( $post_id, $post_author );
if ( ! empty( $autosave ) ) {
wp_delete_post_revision( $autosave->ID );
}
// Check if what is being synced to server is preview post.
$is_saving_preview = 'preview' === $sync_type;
// Preview post aims to have similar post previewing experience as WordPress block editor.
// How preview post works in WordPress block editor:
// 1. Preview button is clicked.
// 2. REST request is sent to `/wp-json/wp/v2/pages/POST_ID/autosaves?_locale=user` endpoint.
// 3. An autosave post for current post is created. Autosave post is another record on `wp_post` table that:
// - has post_status: `inherit`
// - has post_type: `revision`
// - has post_parent pointing to the published / draft post ID
// 4. Basically the autosave post is the most recent revision of the post. A user commonly can have only have
// one autosave per post.
// 5. Once autosave post is created, the preview post URL is generated and returned to the client.
// 6. User is being redirected to the preview post URL
// For WordPress block editor's preview post mechanism, see: `WP_REST_Autosaves_Controller->create_item()`.
if ( $is_saving_preview ) {
// Create new autosave as post revision that will be used for preview.
$revision_id = Revision::put_post_revision(
[
'ID' => $post_id,
'post_title' => $post_content_page_settings['postTitle'] ?? '',
// `_wp_put_post_revision()` already does the `wp_slash()` for the content. No need apply `wp_slash()` here.
'post_content' => $post_content,
],
true
);
// Create preview post link.
// Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response().
$preview_url = get_preview_post_link(
$post_id,
[
'preview_id' => $post_id,
'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
]
);
return self::response_success(
[
'previewId' => $revision_id,
'previewUrl' => $preview_url,
'syncType' => 'preview',
'post_status' => $post_status,
'save_verification' => $revision_id ? true : false,
]
);
}
/**
* Filters whether to proceed with the post save operation.
*
* This filter allows modules to intercept the save process and prevent
* the default wp_update_post() call. If the filter returns an array,
* that array will be returned as the success response and no save will occur.
* If the filter returns null, the save will proceed normally.
*
* @since ??
*
* @param array|null $abort_save_response Response array to return if save should be aborted, or null to proceed.
* @param int $post_id The post ID being saved.
* @param string $post_content The post content being saved.
* @param string $post_status The post status being saved.
* @param array $options The options array containing conditional_tags and other metadata.
* @param array $post_content_page_settings The post content page settings array (if any).
*/
$abort_save_response = apply_filters(
'divi_visual_builder_rest_before_save_post',
null,
$post_id,
$post_content,
$post_status,
$options,
$post_content_page_settings
);
if ( is_array( $abort_save_response ) ) {
// A filter has intercepted the save and provided a custom response.
return self::response_success( $abort_save_response );
}
// Build the post update array.
$post_update_data = [
'ID' => $post_id,
'post_status' => $post_status,
];
// Ignore invalid empty-string post content payloads, while still allowing status/title/excerpt updates.
if ( $has_post_content_payload ) {
$post_update_data['post_content'] = wp_slash( $post_content );
}
// Include post_title and post_excerpt in this update to avoid a second wp_update_post() call.
// This prevents duplicate revisions and duplicate filter processing.
// Note: wp_update_post() internally calls sanitize_post() which sanitizes both fields,
// but we sanitize here for consistency with the existing approach in et_builder_update_settings().
if ( $post_content_page_settings ) {
if ( isset( $post_content_page_settings['postTitle'] ) ) {
$post_update_data['post_title'] = sanitize_text_field( $post_content_page_settings['postTitle'] );
}
if ( isset( $post_content_page_settings['postExcerpt'] ) ) {
// Use wp_kses_post for excerpt to allow certain HTML tags (same as et_builder_update_settings).
$post_update_data['post_excerpt'] = wp_kses_post( $post_content_page_settings['postExcerpt'] );
}
}
$main_post = get_post( $post_id );
$has_main_post_context = $main_post instanceof \WP_Post;
$update = $has_main_post_context ? wp_update_post( $post_update_data ) : 0;
$layout_updates = [
[
'layout' => 'postContent',
'id' => $post_content_post_id,
'content' => $post_content,
],
[
'layout' => 'header',
'id' => $header_post_id,
'content' => $header_content,
],
[
'layout' => 'body',
'id' => $body_post_id,
'content' => $body_content,
],
[
'layout' => 'footer',
'id' => $footer_post_id,
'content' => $footer_content,
],
];
$updated_theme_builder_layout_post_ids = [];
foreach ( $layout_updates as $layout_update ) {
$target_layout = $layout_update['layout'] ?? 'postContent';
$target_post_id = absint( $layout_update['id'] ?? 0 );
$target_content = $layout_update['content'] ?? '';
// Main post content is already updated above.
if ( $post_id === $target_post_id ) {
continue;
}
$layout_update_result = self::_update_layout_post_content( $target_post_id, $target_content );
if ( is_wp_error( $layout_update_result ) ) {
return self::response_error(
$layout_update_result->get_error_code(),
$layout_update_result->get_error_message(),
$layout_update_result->get_error_data()
);
}
if ( 0 !== (int) $layout_update_result && in_array( $target_layout, [ 'header', 'body', 'footer' ], true ) ) {
$updated_theme_builder_layout_post_ids[] = $target_post_id;
}
}
// Ensure Theme Builder layout saves clear template-targeted frontend caches.
// In non-singular Visual Builder contexts, the main request post ID is not a layout post,
// so cache clearing must also run for explicitly saved layout post IDs.
if ( ! empty( $updated_theme_builder_layout_post_ids ) ) {
foreach ( array_unique( $updated_theme_builder_layout_post_ids ) as $updated_layout_post_id ) {
et_theme_builder_clear_wp_post_cache( $updated_layout_post_id );
}
}
// Save page settings by layout post ID.
if ( ! empty( $page_settings_by_layout ) ) {
self::_save_page_settings_by_layout(
$page_settings_by_layout,
[
'postContent' => $post_content_post_id,
'header' => $header_post_id,
'body' => $body_post_id,
'footer' => $footer_post_id,
],
$post_id
);
}
// Save non-singular page settings (term properties, author properties, etc.).
// The frontend syncs field edits back into `mainLoopSettingsData`, so the
// updated values arrive here directly — no separate property needed.
if ( 'singular' !== $main_loop_type && is_array( $loop_settings_data ) ) {
$expected_main_loop_owner_context = self::_get_expected_main_loop_owner_context( $main_loop_type );
SavingUtility::save_non_singular_page_settings(
$loop_settings_data,
$main_loop_type,
$expected_main_loop_owner_context
);
}
/**
* Action hook to fire when the Post is being saved.
*
* This is for backward compatibility with hooks written for Divi version <5.0.0.
*
* @since 3.20
* @deprecated 5.0.0 Use `divi_visual_builder_rest_save_post` hook instead.
*
* @param int $post_id Post ID.
*/
do_action(
'et_save_post',
$post_id
);
/**
* Action hook to fire when the Post is being saved.
*
* @since ??
*
* @param int $post_id Post ID.
*/
// Pass request context into save hooks.
// This avoids relying on globals and is extendable for future non-singular owners.
do_action(
'divi_visual_builder_rest_save_post',
$post_id,
[
'mainLoopType' => $main_loop_type,
'mainLoopSettingsData' => $loop_settings_data,
]
);
// Prime page cache AFTER off-canvas data and other post meta is saved.
// This ensures that when the cache priming request triggers early detection,
// all post meta (including appended canvas content) is available.
SavingUtility::prime_page_cache_on_save( $post_id, $prime_cache_url );
if ( ( $update && ! is_wp_error( $update ) ) || ( ! $has_main_post_context && ! is_wp_error( $update ) ) ) {
// Update post meta so we know D5 is used and Readiness migrator will skip it.
if ( $has_main_post_context ) {
update_post_meta( $post_id, '_et_pb_use_divi_5', 'on' );
// Also set the legacy meta for backward compatibility with D4 components.
update_post_meta( $post_id, '_et_pb_use_builder', 'on' );
// Clear the page creation flow flag so it doesn't show again on subsequent loads.
update_post_meta( $post_id, '_et_pb_show_page_creation', 'off' );
}
$layout_ids_to_mark = [
$post_content_post_id,
$header_post_id,
$body_post_id,
$footer_post_id,
];
foreach ( $layout_ids_to_mark as $layout_id ) {
$layout_id = absint( $layout_id );
if ( 0 === $layout_id || $post_id === $layout_id ) {
continue;
}
update_post_meta( $layout_id, '_et_pb_use_divi_5', 'on' );
update_post_meta( $layout_id, '_et_pb_use_builder', 'on' );
}
$preferences = SavingUtility::sanitize_app_preferences( $preferences );
foreach ( $preferences as $preference_key => $preference_value ) {
$option_name = 'et_fb_pref_' . $preference_key;
et_update_option( $option_name, $preference_value );
}
// Updated last used workspace.
$last_used_workspace = $workspace['last-used'] ?? [];
Workspace::update_item( 'builtIn', 'last-used', $last_used_workspace );
/**
* Action hook to fire when the Post is updated.
*
* This is for backward compatibility with hooks written for Divi version <5.0.0.
*
* @param int $post_id Post ID.
*
* @since 3.29
* @deprecated 5.0.0 Use `divi_visual_builder_rest_update_post` hook instead.
*/
do_action(
'et_update_post',
$post_id
);
/**
* Action hook to fire when the Post is updated.
*
* @param int $post_id Post ID.
*
* @since ??
*/
do_action( 'divi_visual_builder_rest_update_post', $post_id );
$saved_post_content = $has_main_post_context ? get_post_field( 'post_content', $update ) : $post_content;
$verification = $has_main_post_context
? self::_verify_post_content_matches_after_sanitization( $post_content, $saved_post_content )
: true;
/**
* Filter to modify the save verification result.
*
* @since ??
* @deprecated 5.0.0 Use the {@see 'divi_visual_builder_rest_save_verification'} filter instead.
*
* @param bool $verification Whether to save the verification result.
*/
$verification = apply_filters(
'et_fb_ajax_save_verification_result',
$verification
);
/**
* Filter to modify the save verification result.
*
* @since ??
*
* @param bool $verification Whether to save the verification result.
*/
$save_verification_filtered = apply_filters(
'divi_visual_builder_rest_save_post_save_verification',
$verification
);
$return_rendered_content = $options['return_rendered_content'] ?? false;
if ( $return_rendered_content && $has_main_post_context ) {
// Replace dynamic data in the content with the actual value.
$normalized_content = DynamicData::get_processed_dynamic_data( $saved_post_content, $post_id, true );
// Apply the_content filter to the content.
$rendered_content = apply_filters( 'the_content', $normalized_content );
} else {
$rendered_content = $saved_post_content;
}
return self::response_success(
[
'post_status' => $has_main_post_context ? get_post_status( $update ) : $post_status,
'save_verification' => $save_verification_filtered,
'rendered_content' => $rendered_content,
]
);
}
if ( is_wp_error( $update ) ) {
return self::response_error( $update->get_error_code(), $update->get_error_message(), $update->get_error_data() );
}
return self::response_error( 'unknown_error', esc_html__( 'Unknown error.', 'et_builder_5' ) );
}
@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@@ -23,34 +27,35 @@ final class DiviCompatibilityTest extends TestCase
self::assertSame(['B', 'C'], $compatibility->problems());
}
public function testDefaultsCoverTheDiviInternalsInUse(): void
public function testDefaultsAreCodeRegistrationsAndFingerprints(): void
{
$check = new ManifestCheck(
['X' => new Manifest(
Manifest::LIBRARY,
calls: [new Call('strlen', 1)],
postTypes: ['et_pb_layout'],
mirrors: [new Mirror('m', 'a.php', 'a', null, 'Terme')]
)],
[],
static fn (): ?string => null,
static fn (): bool => true
);
self::assertSame(
[
'ModuleRegistration (Modul-Katalog)',
'DynamicContentOptions (Dynamic Content)',
'et_pb_is_allowed (Divi-Rollen)',
'Post-Typ et_pb_layout (Bibliothek)',
'Taxonomien scope, layout_type, module_width (Bibliothek)',
'GlobalLayout::is_global_layout_template (globale Module)',
'ET_Core_PageResource (CSS-Cache)',
'Block-Typ divi/global-layout (Instanzen globaler Module)',
'Taxonomien layout_category, layout_tag (Bibliothek)',
'Theme Builder: Funktionen et_theme_builder_* und et_()',
'Theme Builder: Post-Typen et_template und Layouts',
],
array_keys(DiviCompatibility::defaults())
['Bibliothek: strlen()', 'Bibliothek: Post-Typ et_pb_layout', 'Bibliothek: Nachbau von a() – Terme'],
array_keys(DiviCompatibility::defaults($check))
);
}
/** Review M-2: et_theme_builder_clear_wp_cache is one of DiviThemeBuilder::FUNCTIONS – reported once. */
public function testChecksTheThemeBuilderInternals(): void
public function testRealDefaultsCoverEveryAdapterArea(): void
{
$labels = array_keys(DiviCompatibility::defaults());
$labels = implode("\n", array_keys(DiviCompatibility::defaults()));
self::assertNotContains('et_theme_builder_clear_wp_cache (Theme-Builder-Cache)', $labels);
self::assertContains('Theme Builder: Funktionen et_theme_builder_* und et_()', $labels);
self::assertContains('Theme Builder: Post-Typen et_template und Layouts', $labels);
$areas = [Manifest::PAGES, Manifest::LIBRARY, Manifest::THEME_BUILDER, Manifest::DESIGN, Manifest::CATALOG];
foreach ($areas as $area) {
self::assertStringContainsString($area . ': ', $labels);
}
self::assertStringContainsString('Theme Builder: Nachbau von et_theme_builder_api_save()', $labels);
self::assertStringContainsString('Katalog: Block-Typ divi/global-layout', $labels);
}
}
@@ -19,21 +19,12 @@ use ReflectionClass;
*/
final class DiviBoundaryTest extends TestCase
{
/**
* Files that still touch Divi outside an adapter (paths below src/). Tasks 3–7
* shrink this list to DiviCompatibility.php; Task 8 removes it together with
* testLegacyFilesStillTouchDivi.
*/
private const LEGACY = [
'Integrations/Divi/DiviCompatibility.php',
];
public function testOnlyAdaptersTouchDivi(): void
{
$offenders = [];
foreach (self::sourceFiles() as $relative => $path) {
$touches = DiviTouches::inFile($path);
if ($touches === [] || in_array($relative, self::LEGACY, true)) {
if ($touches === []) {
continue;
}
if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) {
@@ -45,16 +36,6 @@ final class DiviBoundaryTest extends TestCase
. implode("\n", $offenders));
}
public function testLegacyFilesStillTouchDivi(): void
{
$files = self::sourceFiles();
foreach (self::LEGACY as $relative) {
self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen');
self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen');
self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter');
}
}
public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void
{
foreach (DiviAdapters::CLASSES as $class) {
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use PHPUnit\Framework\TestCase;
final class DiviReferenceTest extends TestCase
{
public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void
{
$ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
sort($ids);
$expected = DiviReference::expected();
self::assertSame($ids, array_keys($expected));
foreach ($expected as $id => $hash) {
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id);
self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id);
}
}
public function testWritesAndReadsExpectedFingerprintsSorted(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
try {
DiviReference::writeExpected(['b' => str_repeat('b', 64), 'a' => str_repeat('a', 64)], $file);
self::assertSame(['a' => str_repeat('a', 64), 'b' => str_repeat('b', 64)], DiviReference::expected($file));
self::assertStringEndsWith("\n", (string) file_get_contents($file));
} finally {
unlink($file);
}
}
public function testMissingOrBrokenFileMeansNoExpectations(): void
{
self::assertSame([], DiviReference::expected('/nonexistent/fingerprints.json'));
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use ArrayObject;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class ManifestCheckTest extends TestCase
{
private function check(?\Closure $current = null): ManifestCheck
{
$manifests = [
'A' => new Manifest(
Manifest::PAGES,
calls: [
new Call('strlen', 1),
new Call('lit_mcp_missing_function', 0),
new Call(ArrayObject::class . '::count', 0),
new Call('Missing\Klass::run', 0),
new Call('str_replace'),
],
constants: ['PHP_VERSION', 'LIT_MCP_MISSING_CONSTANT'],
postTypes: ['et_pb_layout'],
taxonomies: ['scope'],
blockTypes: ['divi/global-layout'],
mirrors: [
new Mirror('same', 'a.php', 'a', null, 'gleich'),
new Mirror('changed', 'b.php', 'b', null, 'geändert'),
new Mirror('unreadable', 'c.php', 'c', null, 'fehlt'),
new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
]
),
];
$currentHashes = ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'];
return new ManifestCheck(
$manifests,
['same' => 'h1', 'changed' => 'h2', 'unreadable' => 'h3'],
$current ?? static fn (Mirror $mirror): ?string => $currentHashes[$mirror->id] ?? null,
static fn (string $name): bool => false
);
}
public function testCodeChecksCoverExistenceAndArgumentCount(): void
{
self::assertSame(
[
'Seiten: strlen()',
'Seiten: lit_mcp_missing_function()',
'Seiten: ArrayObject::count()',
'Seiten: Klass::run()',
'Seiten: str_replace()',
'Seiten: Konstante PHP_VERSION',
'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
],
array_keys($this->check()->codeChecks())
);
self::assertSame(
[
'Seiten: lit_mcp_missing_function()',
'Seiten: Klass::run()',
'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
],
ManifestCheck::failed($this->check()->codeChecks())
);
}
public function testAcceptsChecksTheArgumentCountTheAdapterPasses(): void
{
self::assertTrue(ManifestCheck::accepts(new Call('strlen', 1)));
self::assertFalse(ManifestCheck::accepts(new Call('strlen', 2)), 'strlen takes one argument');
self::assertFalse(ManifestCheck::accepts(new Call('str_replace', 2)), 'str_replace needs three');
self::assertTrue(ManifestCheck::accepts(new Call('sprintf', 5)), 'variadic');
self::assertTrue(ManifestCheck::accepts(new Call('str_replace')), 'existence only');
}
public function testMirrorChecksCompareWithTheExpectedFingerprints(): void
{
$checks = $this->check()->mirrorChecks();
self::assertSame(
[
'Seiten: Nachbau von a() – gleich',
'Seiten: Nachbau von b() – geändert',
'Seiten: Nachbau von c() – fehlt',
'Seiten: Nachbau von d() – ohne Soll',
],
array_keys($checks)
);
self::assertSame(
[
'Seiten: Nachbau von b() – geändert',
'Seiten: Nachbau von c() – fehlt',
'Seiten: Nachbau von d() – ohne Soll',
],
ManifestCheck::failed($checks)
);
}
public function testUnreadableMirrorIsAFailedCheck(): void
{
$check = $this->check(static function (): ?string {
throw new RuntimeException('Divi file gone');
});
self::assertCount(4, ManifestCheck::failed($check->mirrorChecks()));
}
public function testRegistrationChecksAskWordPressAndDivi(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('taxonomy_exists')->justReturn(false);
$checks = $this->check()->registrationChecks();
self::assertSame(
['Seiten: Post-Typ et_pb_layout', 'Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
array_keys($checks)
);
self::assertSame(
['Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
ManifestCheck::failed($checks)
);
}
}