From bcc87664f450e6a071231a96bd5f4bcf2fd8e37d Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:58:48 +0300 Subject: [PATCH] fix: builder layout meta stays raw, never modelled as content (too_large on deep Elementor trees) Page-builder meta (Policy::BUILDER: _elementor_*, _et_pb_*) was modelled into meta_* fields and wp-structured-values rows; a deeply nested _elementor_data tree exceeded the modelling depth and failed the whole export (too_large). It now travels only in raw-posts / RawIR, matching @contentrain/wp-import, which never turns underscore meta into content fields. Past the modelling depth, value() keeps the remaining subtree as JSON text and records a nesting-too-deep warning instead of throwing. --- includes/class-contentrain-bridge-models.php | 14 ++++- tests/builder.php | 64 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/includes/class-contentrain-bridge-models.php b/includes/class-contentrain-bridge-models.php index b02f8ee..cf4b30f 100644 --- a/includes/class-contentrain-bridge-models.php +++ b/includes/class-contentrain-bridge-models.php @@ -221,7 +221,10 @@ public static function post( &$job, $record ) { $data[ $key ][] = $target['id']; } foreach ( $p['meta'] as $key => $value ) { - if ( null === $value || in_array( $key, Policy::CORE, true ) ) { + // Builder layout meta (Elementor tree, Divi switches) is not content: it travels only in + // raw-posts / RawIR, where a migration rebuilds the page from it. `@contentrain/wp-import` + // drops it the same way, so it never becomes a `meta_*` field or structured-value rows. + if ( null === $value || in_array( $key, Policy::CORE, true ) || preg_match( Policy::BUILDER, $key ) ) { continue; } $name = 'meta_' . str_replace( '-', '_', sanitize_key( ltrim( $key, '_' ) ) ); @@ -285,11 +288,16 @@ public static function post( &$job, $record ) { } } - /** Complex fields become editable, ordered related records instead of opaque JSON blobs. */ + /** + * Complex fields become editable, ordered related records instead of opaque JSON blobs. + * Past the modelling depth the rest of the subtree is kept as JSON text and reported: + * a visible gap, not a failed export. + */ private static function value( &$job, $value, $locale, $source, $depth = 0 ) { if ( is_array( $value ) ) { if ( $depth > 10 ) { - throw new \RuntimeException( 'Content nesting exceeds the supported modelling depth.' ); + Jobs::warning( $job, array( 'source' => $source, 'reason' => 'nesting-too-deep: kept as JSON text' ) ); + return array( array( 'type' => 'text' ), (string) wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } $fields = array( 'name' => array( 'type' => 'string' ), 'position' => array( 'type' => 'integer' ), 'text' => array( 'type' => 'text' ), 'number' => array( 'type' => 'number' ), 'boolean' => array( 'type' => 'boolean' ), 'children' => array( 'type' => 'relations', 'model' => 'wp-structured-values' ) ); self::model( $job, 'wp-structured-values', 'collection', 'site', 'Structured fields', $fields, 'name' ); diff --git a/tests/builder.php b/tests/builder.php index 583512c..3e9f1f6 100644 --- a/tests/builder.php +++ b/tests/builder.php @@ -9,6 +9,10 @@ } require_once WP_PLUGIN_DIR . '/contentrain-bridge/contentrain-bridge.php'; use Contentrain\Bridge\Exporter; +use Contentrain\Bridge\Files; +use Contentrain\Bridge\Jobs; +use Contentrain\Bridge\Models; +use Contentrain\Bridge\Source; $checks = 0; function check( $value, $message ) { @@ -36,6 +40,17 @@ function check( $value, $message ) { $deep = array( 'id' => 'c' . $i, 'elType' => 'container', 'settings' => array(), 'elements' => array( $deep ) ); } $tree[] = $deep; +// A golden Elementor page nests far deeper than content modelling goes: twelve containers. +$golden = array( 'id' => 'g12', 'elType' => 'widget', 'widgetType' => 'heading', 'settings' => array( 'title' => 'Golden deep' ), 'elements' => array() ); +for ( $i = 0; $i < 12; $i++ ) { + $golden = array( 'id' => 'gc' . $i, 'elType' => 'container', 'settings' => array(), 'elements' => array( $golden ) ); +} +$tree[] = $golden; +// Selected, non-builder meta twelve arrays deep: past the modelling depth (the array at depth eleven). +$deep_meta = 'bottom'; +for ( $i = 11; $i >= 0; $i-- ) { + $deep_meta = array( 'l' . $i => $deep_meta ); +} $created = array(); $open = wp_insert_post( array( 'post_type' => 'page', 'post_title' => 'Builder page', 'post_content' => '

Welcome

', 'post_status' => 'publish' ) ); $locked = wp_insert_post( array( 'post_type' => 'page', 'post_title' => 'Builder locked', 'post_content' => '

Members

', 'post_status' => 'publish', 'post_password' => 'let-me-in' ) ); @@ -48,6 +63,7 @@ function check( $value, $message ) { update_post_meta( $id, '_elementor_page_settings', array( 'hide_title' => 'yes' ) ); } update_post_meta( $divi, '_et_pb_use_builder', 'on' ); +update_post_meta( $divi, 'bridge_deep_meta', $deep_meta ); update_post_meta( $divi, '_et_pb_old_content', '

Before Divi

' ); update_post_meta( $kit, '_elementor_page_settings', array( 'system_colors' => array( array( '_id' => 'primary', 'title' => 'Primary', 'color' => '#6EC1E4' ) ), 'container_width' => array( 'size' => 1140 ), 'recaptcha_secret_key' => 'shh' ) ); $previous_kit = get_option( 'elementor_active_kit' ); @@ -93,6 +109,54 @@ function check( $value, $message ) { } else { check( ! isset( $options['block_templates'] ), 'no block templates for a classic theme' ); } + // A full export: builder meta stays raw evidence, never content fields; too-deep content is kept as JSON text. + $admin = get_user_by( 'login', 'bridge-admin' ); + wp_set_current_user( $admin->ID ); + $previous_job = get_user_meta( $admin->ID, 'contentrain_bridge_job_1', true ); + delete_user_meta( $admin->ID, 'contentrain_bridge_job_1' ); + $export = null; + try { + $s = Jobs::create( array( 'types' => array( 'page' ), 'private' => false, 'scan_sources' => false, 'selected_meta' => array( 'bridge_deep_meta' ) ) ); + $export = $s['id']; + for ( $i = 0; $i < 500 && ! in_array( $s['phase'], array( 'review', 'failed' ), true ); ++$i ) { $s = Jobs::step( $export, $s['step'] ); } + check( 'review' === $s['phase'], 'a page with a twelve-container Elementor tree exports without error' ); + $job = Jobs::read( $export ); + $address = Source::address( get_post( $open ) ); + $page_fields = $job['models'][ $address['model_id'] ]['fields']; + $builder_fields = array_filter( array_keys( $page_fields ), static function ( $f ) { return 0 === strpos( $f, 'meta_elementor_' ) || 0 === strpos( $f, 'meta_et_pb_' ); } ); + check( ! $builder_fields, 'builder meta is not modelled as content fields (wp-import parity)' ); + $raw_open = json_decode( Files::read( Files::dir( $export ), Models::row_file( 'bridge/raw-posts.json', $open ) ), true ); + $node = $raw_open['meta']['_elementor_data'][2] ?? array(); + for ( $i = 0; $i < 12; $i++ ) { + $node = $node['elements'][0] ?? array(); + } + check( 'Golden deep' === ( $node['settings']['title'] ?? '' ), 'the whole Elementor tree still travels in raw-posts' ); + $raw_divi = json_decode( Files::read( Files::dir( $export ), Models::row_file( 'bridge/raw-posts.json', $divi ) ), true ); + check( 'on' === ( $raw_divi['meta']['_et_pb_use_builder'] ?? '' ), 'Divi builder switch stays in raw-posts' ); + $divi_address = Source::address( get_post( $divi ) ); + $source = $divi_address['model_id'] . '/' . $divi_address['entry_id'] . '/bridge_deep_meta'; + check( 'wp-structured-values' === ( $page_fields['meta_bridge_deep_meta']['model'] ?? null ), 'deep non-builder meta is still modelled as structured values' ); + $path = $source; + for ( $i = 0; $i <= 10; $i++ ) { + $path .= '/l' . $i; + } + $row = json_decode( Files::read( Files::dir( $export ), Models::row_file( Models::content_path( $job, 'wp-structured-values', $divi_address['locale'] ), substr( hash( 'sha256', $path ), 0, 12 ) ) ), true ); + check( '{"l11":"bottom"}' === ( $row['text'] ?? null ), 'past the modelling depth the subtree is kept as JSON text' ); + $warnings = array(); + foreach ( array_keys( $job['tables']['bridge/warnings.json'] ?? array() ) as $key ) { + $warnings[] = json_decode( Files::read( Files::dir( $export ), Models::row_file( 'bridge/warnings.json', $key ) ), true ); + } + $gap = array_filter( $warnings, static function ( $w ) use ( $path ) { return $path === ( $w['source'] ?? '' ) && 'nesting-too-deep: kept as JSON text' === ( $w['reason'] ?? '' ); } ); + check( 1 === count( $gap ), 'and the gap is reported as a warning' ); + } finally { + if ( $export ) { + Jobs::delete( $export ); + } + if ( $previous_job ) { + update_user_meta( $admin->ID, 'contentrain_bridge_job_1', $previous_job ); + } + } + $json = wp_json_encode( $options ) . wp_json_encode( Exporter::map_post( get_post( $open ) ) ); $planted = array( 'planted-key', 'abc123-us1', 'owner@example.test', 'let-me-in', 'planted-zap', 'planted-discord', 'planted-slack' ); check( array() === array_filter( $planted, static function ( $p ) use ( $json ) { return false !== strpos( $json, $p ); } ), 'no planted secret anywhere in the output' );