Row -> Column -> * Module hierarchy. Unknown fields are valid extension-owned data. Rows may * omit their type for backwards compatibility. */ final class Layout_Schema { const CURRENT_VERSION = 1; const VERSION_META = '_nyxonn_builder_layout_schema_version'; const MAX_NODES = 5000; const MAX_DEPTH = 16; /** Return the version represented by an already-decoded layout. */ public static function detect_version( $layout ) { return is_array( $layout ) ? self::CURRENT_VERSION : 0; } /** Decode and validate a raw persisted JSON layout. */ public static function decode( $json ) { if ( ! is_string( $json ) || '' === trim( $json ) || '[' !== substr( ltrim( $json ), 0, 1 ) ) { return new \WP_Error( 'invalid_layout_root', __( 'Layout root must be a JSON array.', 'nyxonn-builder' ) ); } $typed_layout = json_decode( $json ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $typed_layout ) || ! self::validate_json_collection_types( $typed_layout, 'section' ) ) { return new \WP_Error( 'invalid_layout_json_types', __( 'Layout objects and child collections have invalid JSON types.', 'nyxonn-builder' ) ); } $layout = json_decode( $json, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $layout ) ) { return new \WP_Error( 'invalid_layout_json', __( 'Layout JSON could not be decoded.', 'nyxonn-builder' ) ); } $valid = self::validate( $layout ); return is_wp_error( $valid ) ? $valid : $layout; } /** Validate structure without removing safe unknown extension fields. */ public static function validate( $layout ) { if ( ! is_array( $layout ) || ! self::is_list( $layout ) ) { return new \WP_Error( 'invalid_layout_root', __( 'Layout root must be a list of sections.', 'nyxonn-builder' ) ); } $count = 0; $node_ids = []; return self::validate_nodes( $layout, 'section', 1, $count, $node_ids ); } /** * Migration runner. No migrations are registered because all current * valid layouts are schema v1; viewing a layout never invokes this method. */ public static function migrate( $layout, $from_version, $to_version = self::CURRENT_VERSION ) { $from_version = intval( $from_version ); $to_version = intval( $to_version ); if ( self::CURRENT_VERSION === $from_version && self::CURRENT_VERSION === $to_version ) { return $layout; } return new \WP_Error( 'layout_migration_unavailable', sprintf( /* translators: 1: source schema version, 2: destination schema version. */ __( 'No layout migration is registered from schema v%1$d to v%2$d.', 'nyxonn-builder' ), $from_version, $to_version ) ); } private static function validate_nodes( $nodes, $expected_kind, $depth, &$count, &$node_ids ) { if ( $depth > self::MAX_DEPTH ) { return new \WP_Error( 'layout_too_deep', __( 'Layout nesting exceeds the supported depth.', 'nyxonn-builder' ) ); } if ( ! is_array( $nodes ) || ! self::is_list( $nodes ) ) { return new \WP_Error( 'invalid_layout_children', __( 'Layout children must be a list.', 'nyxonn-builder' ) ); } foreach ( $nodes as $node ) { ++$count; if ( $count > self::MAX_NODES ) { return new \WP_Error( 'layout_too_large', __( 'Layout contains too many nodes.', 'nyxonn-builder' ) ); } if ( ! is_array( $node ) || self::is_list( $node ) ) { return new \WP_Error( 'invalid_layout_node', __( 'Every layout node must be an object.', 'nyxonn-builder' ) ); } $kind = self::node_kind( $node ); if ( '' === $kind || $kind !== $expected_kind ) { return new \WP_Error( 'invalid_layout_hierarchy', __( 'Layout nodes are not in a supported hierarchy.', 'nyxonn-builder' ) ); } if ( isset( $node['settings'] ) && ! is_array( $node['settings'] ) ) { return new \WP_Error( 'invalid_layout_settings', __( 'Layout node settings must be an object.', 'nyxonn-builder' ) ); } if ( isset( $node['nodeId'] ) && ( ! is_string( $node['nodeId'] ) || strlen( $node['nodeId'] ) > 160 || ! preg_match( '/^[A-Za-z0-9_-]+$/', $node['nodeId'] ) ) ) { return new \WP_Error( 'invalid_layout_node_id', __( 'Layout node ID is invalid.', 'nyxonn-builder' ) ); } if ( isset( $node['nodeId'] ) ) { if ( isset( $node_ids[ $node['nodeId'] ] ) ) { return new \WP_Error( 'duplicate_layout_node_id', __( 'Layout node IDs must be unique.', 'nyxonn-builder' ) ); } $node_ids[ $node['nodeId'] ] = true; } $child_key = ''; $child_kind = ''; if ( 'section' === $kind ) { $child_key = 'rows'; $child_kind = 'row'; } elseif ( 'row' === $kind ) { $child_key = 'columns'; $child_kind = 'column'; } elseif ( 'column' === $kind ) { $child_key = array_key_exists( 'modules', $node ) ? 'modules' : ( array_key_exists( 'children', $node ) ? 'children' : '' ); $child_kind = 'module'; } if ( $child_key ) { if ( ! array_key_exists( $child_key, $node ) || ! is_array( $node[ $child_key ] ) ) { return new \WP_Error( 'invalid_layout_children', __( 'Layout children must be a list.', 'nyxonn-builder' ) ); } $valid = self::validate_nodes( $node[ $child_key ], $child_kind, $depth + 1, $count, $node_ids ); if ( is_wp_error( $valid ) ) { return $valid; } } } return true; } private static function node_kind( $node ) { $type = isset( $node['type'] ) && is_string( $node['type'] ) ? $node['type'] : ''; if ( 'section' === $type ) { return 'section'; } if ( 'column' === $type ) { return 'column'; } if ( 'row' === $type || array_key_exists( 'columns', $node ) ) { return 'row'; } if ( '' !== $type && strlen( $type ) <= 100 && preg_match( '/^[a-z][a-z0-9_-]*$/', $type ) ) { return 'module'; } return ''; } /** Preserve the JSON distinction between {} and [] for structural fields. */ private static function validate_json_collection_types( $nodes, $expected_kind ) { if ( ! is_array( $nodes ) ) { return false; } foreach ( $nodes as $node ) { if ( ! is_object( $node ) ) { return false; } if ( 'section' === $expected_kind ) { if ( ! property_exists( $node, 'rows' ) || ! self::validate_json_collection_types( $node->rows, 'row' ) ) { return false; } } elseif ( 'row' === $expected_kind ) { if ( ! property_exists( $node, 'columns' ) || ! self::validate_json_collection_types( $node->columns, 'column' ) ) { return false; } } elseif ( 'column' === $expected_kind ) { $children = property_exists( $node, 'modules' ) ? $node->modules : ( property_exists( $node, 'children' ) ? $node->children : [] ); if ( ! self::validate_json_collection_types( $children, 'module' ) ) { return false; } } } return true; } private static function is_list( $value ) { if ( [] === $value ) { return true; } return array_keys( $value ) === range( 0, count( $value ) - 1 ); } }