diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 404cccb3..bf15db56 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -6,6 +6,7 @@ /vendor/ /node_modules/ /lang/* + /includes/acf-pattern-functions.php diff --git a/assets/src/js/bindings/custom-sources.js b/assets/src/js/bindings/custom-sources.js new file mode 100644 index 00000000..c91c4164 --- /dev/null +++ b/assets/src/js/bindings/custom-sources.js @@ -0,0 +1,192 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; +import { registerBlockBindingsSource } from '@wordpress/blocks'; +import { store as coreDataStore } from '@wordpress/core-data'; + +/** + * Get the value of a specific field from the ACF fields. + * + * @param {Object} fields The ACF fields object. + * @param {string} fieldName The name of the field to retrieve. + * @returns {string} The value of the specified field, or undefined if not found. + */ +const getFieldValue = ( fields, fieldName ) => fields?.acf?.[ fieldName ]; + +const resolveImageAttribute = ( imageObj, attribute ) => { + if ( ! imageObj ) return ''; + switch ( attribute ) { + case 'id': + return imageObj.id; + case 'url': + case 'content': + return imageObj.source_url; + case 'alt': + return imageObj.alt_text || ''; + case 'title': + return imageObj.title?.rendered || ''; + default: + return ''; + } +}; + +registerBlockBindingsSource( { + name: 'scf/experimental-field', + label: 'SCF Custom Fields', + getValues( { context, bindings, select } ) { + const { getEditedEntityRecord, getMedia } = select( coreDataStore ); + let fields = + context?.postType && context?.postId + ? getEditedEntityRecord( + 'postType', + context.postType, + context.postId + ) + : undefined; + const result = {}; + + Object.entries( bindings ).forEach( + ( [ attribute, { args } = {} ] ) => { + const fieldName = args?.field; + + const fieldValue = getFieldValue( fields, fieldName ); + if ( typeof fieldValue === 'object' && fieldValue !== null ) { + result[ attribute ] = + ( fieldValue[ attribute ] ?? + ( attribute === 'content' && fieldValue.url ) ) || + ''; + } else if ( typeof fieldValue === 'number' ) { + if ( attribute === 'content' ) { + result[ attribute ] = fieldValue.toString() || ''; + } else { + const imageObj = getMedia( fieldValue ); + result[ attribute ] = resolveImageAttribute( + imageObj, + attribute + ); + } + } else { + result[ attribute ] = fieldValue || ''; + } + } + ); + return result; + }, + async setValues( { context, bindings, dispatch, select } ) { + const { getEditedEntityRecord } = select( coreDataStore ); + if ( ! bindings || ! context?.postType || ! context?.postId ) return; + + const currentPost = getEditedEntityRecord( + 'postType', + context.postType, + context.postId + ); + const currentAcfData = currentPost?.acf || {}; + const fieldsToUpdate = {}; + + for ( const [ attribute, binding ] of Object.entries( bindings ) ) { + const fieldName = binding?.args?.field; + const newValue = binding?.newValue; + if ( ! fieldName || newValue === undefined ) continue; + if ( ! fieldsToUpdate[ fieldName ] ) { + fieldsToUpdate[ fieldName ] = newValue; + } else if ( + attribute === 'url' && + typeof fieldsToUpdate[ fieldName ] === 'object' + ) { + fieldsToUpdate[ fieldName ] = { + ...fieldsToUpdate[ fieldName ], + url: newValue, + }; + } else if ( attribute === 'id' && typeof newValue === 'number' ) { + fieldsToUpdate[ fieldName ] = newValue; + } + } + + const allAcfFields = { ...currentAcfData, ...fieldsToUpdate }; + const processedAcfData = {}; + + for ( const [ key, value ] of Object.entries( allAcfFields ) ) { + // Handle specific field types requiring proper type conversion + if ( value === '' ) { + // Convert empty strings to appropriate types based on field name + if ( + key === 'number' || + key.includes( '_number' ) || + /number$/.test( key ) + ) { + // Number fields should be null when empty + processedAcfData[ key ] = null; + } else if ( key.includes( 'range' ) || key === 'range_type' ) { + // Range fields should be null when empty + processedAcfData[ key ] = null; + } else if ( key.includes( '_date' ) ) { + // Date fields should be null when empty + processedAcfData[ key ] = null; + } else if ( key.includes( 'email' ) || key === 'email_type' ) { + // Handle email fields + processedAcfData[ key ] = null; + } else if ( key.includes( 'url' ) || key === 'url_type' ) { + // Handle URL fields + processedAcfData[ key ] = null; + } else { + // Other fields can remain as empty strings + processedAcfData[ key ] = value; + } + } else if ( value === 0 || value ) { + // Non-empty values - ensure numbers are actually numbers + if ( + ( key === 'number' || + key.includes( '_number' ) || + /number$/.test( key ) ) && + value !== null + ) { + // Convert string numbers to actual numbers if needed + const numValue = parseFloat( value ); + processedAcfData[ key ] = isNaN( numValue ) + ? null + : numValue; + } else { + processedAcfData[ key ] = value; + } + } else { + // null, undefined, etc. + processedAcfData[ key ] = value; + } + } + + dispatch( coreDataStore ).editEntityRecord( + 'postType', + context.postType, + context.postId, + { + acf: processedAcfData, + meta: { _acf_changed: 1 }, + } + ); + }, + canUserEditValue( { select, context, args } ) { + // Lock editing in query loop. + if ( context?.query || context?.queryId ) { + return false; + } + + // Lock editing when `postType` is not defined. + if ( ! context?.postType ) { + return false; + } + + // Check that the user has the capability to edit post meta. + const canUserEdit = select( coreDataStore ).canUser( 'update', { + kind: 'postType', + name: context?.postType, + id: context?.postId, + } ); + if ( ! canUserEdit ) { + return false; + } + + return true; + }, +} ); diff --git a/assets/src/js/bindings/index.js b/assets/src/js/bindings/index.js new file mode 100644 index 00000000..4102bb53 --- /dev/null +++ b/assets/src/js/bindings/index.js @@ -0,0 +1 @@ +import './custom-sources.js'; diff --git a/includes/Blocks/Bindings.php b/includes/Blocks/Bindings.php index 921558c3..aad5ca35 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -31,7 +31,6 @@ public function __construct() { * Hooked to acf/init, register our binding sources. */ public function register_binding_sources() { - if ( acf_get_setting( 'enable_block_bindings' ) ) { register_block_bindings_source( 'acf/field', array( @@ -39,6 +38,71 @@ public function register_binding_sources() { 'get_value_callback' => array( $this, 'get_value' ), ) ); + register_block_bindings_source( + 'scf/experimental-field', + array( + 'label' => _x( 'SCF Fields', 'The core SCF block binding source name for fields on the current page', 'secure-custom-fields' ), + 'uses_context' => array( 'postId', 'postType' ), + 'get_value_callback' => array( $this, 'scf_get_block_binding_value' ), + ) + ); + } + + /** + * Handle returning the block binding value for an ACF meta value. + * + * @since SCF 6.5 + * + * @param array $source_attrs An array of the source attributes requested. + * @param \WP_Block $block_instance The block instance. + * @param string $attribute_name The block's bound attribute name. + * @return string|null The block binding value or an empty string on failure. + */ + public function scf_get_block_binding_value( $source_attrs, $block_instance, $attribute_name ) { + $post_id = $block_instance->context['postId'] ?? get_the_ID(); + + // Ensure we're using the parent post ID if this is a revision + if ( $post_id && wp_is_post_revision( $post_id ) ) { + $post_id = wp_get_post_parent_id( $post_id ); + } + + $field_name = $source_attrs['field'] ?? ''; + + if ( ! $post_id || ! $field_name ) { + return ''; + } + + $value = get_field( $field_name, $post_id ); + // Handle different field types based on attribute + switch ( $attribute_name ) { + case 'content': + return is_array( $value ) ? ( $value['alt'] ?? '' ) : (string) $value; + case 'url': + if ( is_array( $value ) && isset( $value['url'] ) ) { + return $value['url']; + } + if ( is_numeric( $value ) ) { + return wp_get_attachment_url( $value ); + } + return (string) $value; + case 'alt': + if ( is_array( $value ) && isset( $value['alt'] ) ) { + return $value['alt']; + } + if ( is_numeric( $value ) ) { + return get_post_meta( $value, '_wp_attachment_image_alt', true ); + } + return ''; + case 'id': + if ( is_array( $value ) && isset( $value['id'] ) ) { + return (string) $value['id']; + } + if ( is_numeric( $value ) ) { + return (string) $value; + } + return ''; + default: + return is_string( $value ) ? $value : ''; } } diff --git a/includes/acf-form-functions.php b/includes/acf-form-functions.php index d025937d..cf3559aa 100644 --- a/includes/acf-form-functions.php +++ b/includes/acf-form-functions.php @@ -129,6 +129,11 @@ function acf_save_post( $post_id = 0, $values = null ) { return false; } + // Prevent auto-save, as we do it before in custom-sources.js. + if ( get_option( 'scf_beta_feature_code_patterns_enabled' ) ) { + return false; + } + // Set form data (useful in various filters/actions). acf_set_form_data( 'post_id', $post_id ); diff --git a/includes/acf-pattern-functions.php b/includes/acf-pattern-functions.php new file mode 100644 index 00000000..34e50b20 --- /dev/null +++ b/includes/acf-pattern-functions.php @@ -0,0 +1,247 @@ + 'Title', + 'slug' => 'Slug', + 'categories' => 'Categories', + 'keywords' => 'Keywords', + 'description' => 'Description', + 'scf_fieldgroup' => 'SCF Fieldgroup', + ); + + $meta_data = get_file_data( $pattern_directory, $headers ); + + if ( empty( $meta_data['title'] ) || empty( $meta_data['slug'] ) ) { + return new WP_Error( 'invalid_pattern', 'Pattern missing required title or slug' ); + } + + // Use the new pattern loading method that doesn't require output buffering + $pattern_content = scf_load_pattern_from_file( $pattern_directory ); + + if ( is_wp_error( $pattern_content ) ) { + return $pattern_content; + } + + // Process metadata + $categories = ! empty( $meta_data['categories'] ) ? + array_map( 'trim', explode( ',', $meta_data['categories'] ) ) : array( 'text' ); + $keywords = ! empty( $meta_data['keywords'] ) ? + array_map( 'trim', explode( ',', $meta_data['keywords'] ) ) : array(); + + // Register pattern + return register_block_pattern( + $meta_data['slug'], + array( + 'title' => $meta_data['title'], + 'categories' => $categories, + 'keywords' => $keywords, + 'description' => array_key_exists( 'description', $meta_data ) ? $meta_data['description'] : __( 'SCF Pattern', 'secure-custom-fields' ), + 'content' => $pattern_content, + ) + ); +} + +function experimental_create_block_with_binding( string $tag, array $bindings_args = array(), string $inner_content = '' ) { + // If tag is specified, map it to the appropriate block type + $block = 'core/paragraph'; // Default block type + $wrapper_tag = 'p'; // Default HTML wrapper tag + $attributes = array(); // Block attributes + + if ($tag !== null) { + switch ($tag) { + case 'p': + $block = 'core/paragraph'; + $wrapper_tag = 'p'; + break; + case 'h1': + $block = 'core/heading'; + $wrapper_tag = 'h1'; + $attributes['level'] = 1; + break; + case 'h2': + case 'h': // Support legacy 'h' tag as h2 + $block = 'core/heading'; + $wrapper_tag = 'h2'; + $attributes['level'] = 2; + break; + case 'h3': + $block = 'core/heading'; + $wrapper_tag = 'h3'; + $attributes['level'] = 3; + break; + case 'h4': + $block = 'core/heading'; + $wrapper_tag = 'h4'; + $attributes['level'] = 4; + break; + case 'h5': + $block = 'core/heading'; + $wrapper_tag = 'h5'; + $attributes['level'] = 5; + break; + case 'h6': + $block = 'core/heading'; + $wrapper_tag = 'h6'; + $attributes['level'] = 6; + break; + case 'figure': + $block = 'core/image'; + $wrapper_tag = 'figure'; + break; + case 'img': + $block = 'core/image'; + $wrapper_tag = 'figure'; + break; + case 'button': + $block = 'core/button'; + $wrapper_tag = 'div'; + break; + case 'div': + $block = 'core/group'; + $wrapper_tag = 'div'; + break; + default: + $block = 'core/paragraph'; + $wrapper_tag = 'p'; + break; + } + } + + + // Create inner content with the correct HTML structure + $class_attr = ''; + if (strpos($block, 'heading') !== false) { + $class_attr = ' class="wp-block-heading"'; + } + if (strpos($block, 'image') !== false) { + $class_attr = ' class="wp-block-image"'; + } + + // Generate content with proper HTML structure + if (empty(trim($inner_content))) { + if ($tag === 'img' || $tag === 'figure') { + $inner_content = sprintf('<%1$s%3$s>%2$s', $wrapper_tag, esc_attr(''), $class_attr); + } else { + $inner_content = sprintf('<%1$s%3$s>%2$s', $wrapper_tag, esc_attr(''), $class_attr); + } + + } else { + // Check if we need to add proper HTML structure + if (!preg_match('/^\s*<' . preg_quote($wrapper_tag, '/') . '[\s>]/i', $inner_content)) { + // Add class for headings if needed + $inner_content = sprintf('<%1$s%3$s>%2$s', $wrapper_tag, $inner_content, $class_attr); + } + } + + // Build block attributes JSON + $attr_json = ''; + if (!empty($bindings_args)) { + // Initialize metadata bindings array + $attributes['metadata'] = array( + 'bindings' => array() + ); + + // Process each binding argument + foreach ((array)$bindings_args as $binding) { + // Check if this is a properly formatted binding + if (isset($binding['attribute']) && isset($binding['field'])) { + $attributes['metadata']['bindings'][$binding['attribute']] = array( + // TODO: We can pass the source as a variable so it will work with any source. + 'source' => 'scf/experimental-field', + 'args' => array( + 'field' => $binding['field'] + ) + ); + } + } + + $attr_json = wp_json_encode($attributes); + } + + // Format according to WordPress block structure + $content = sprintf( + ' +%s +', + esc_attr($block), + $attr_json, + $inner_content, + esc_attr($block) + ); + + return $content; +} + +/** + * Load pattern content from a file by reading and processing it directly. + * + * This is the recommended method to use instead of scf_parse_pattern_file() + * as it doesn't rely on output buffering, which can cause issues. + * + * @since SCF 6.5.1 + * @param string $pattern_file The pattern file path. + * @return string|WP_Error The pattern content or WP_Error on failure. + */ +function scf_load_pattern_from_file( $pattern_file ) { + if ( ! file_exists( $pattern_file ) || ! is_readable( $pattern_file ) ) { + return new WP_Error( 'pattern_not_found', 'Pattern file not found or not readable' ); + } + + // For PHP files, execute directly without reading the entire file first + if ( pathinfo( $pattern_file, PATHINFO_EXTENSION ) === 'php' ) { + try { + // Create a closure that mimics the include environment but returns the content + $sandbox = function( $file_path ) { + ob_start(); + $result = include $file_path; + $output = ob_get_clean(); + + // If the file returns a string directly (recommended pattern), + // use that instead of captured output + if ( is_string( $result ) ) { + return $result; + } + + // Otherwise return the captured output + return $output; + }; + + $pattern_content = $sandbox( $pattern_file ); + } catch ( Exception $e ) { + return new WP_Error( 'pattern_execution_error', $e->getMessage() ); + } + } else { + // For non-PHP files (like HTML), only now do we read the file + $pattern_content = file_get_contents( $pattern_file ); + if ( false === $pattern_content ) { + return new WP_Error( 'pattern_read_error', 'Unable to read pattern file contents' ); + } + } + + // Wrap the pattern content in a group block if it's not already a group block + if (!preg_match('/^\n
%s
\n", + $pattern_content + ); + } + + return $pattern_content; +} diff --git a/includes/admin/beta-features.php b/includes/admin/beta-features.php index 1051fd58..5f32bf49 100644 --- a/includes/admin/beta-features.php +++ b/includes/admin/beta-features.php @@ -35,8 +35,7 @@ class SCF_Admin_Beta_Features { * @return void */ public function __construct() { - // Temporarily disabled - will be enabled when beta feature is ready - // add_action( 'admin_menu', array( $this, 'admin_menu' ), 20 ); + add_action( 'admin_menu', array( $this, 'admin_menu' ), 20 ); } /** @@ -88,7 +87,8 @@ public function get_beta_features() { public function localize_beta_features() { $beta_features = array(); foreach ( $this->get_beta_features() as $name => $beta_feature ) { - $beta_features[ $name ] = $beta_feature->is_enabled(); + $is_enabled = $beta_feature->is_enabled(); + $beta_features[ $name ] = $is_enabled; } acf_localize_data( @@ -155,7 +155,7 @@ public function admin_body_class( $classes ) { */ private function include_beta_features() { acf_include( 'includes/admin/beta-features/class-scf-beta-feature.php' ); - acf_include( 'includes/admin/beta-features/class-scf-beta-feature-editor-sidebar.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-code-patterns.php' ); add_action( 'scf/include_admin_beta_features', array( $this, 'register_beta_features' ) ); @@ -170,7 +170,7 @@ private function include_beta_features() { * @return void */ public function register_beta_features() { - scf_register_admin_beta_feature( 'SCF_Admin_Beta_Feature_Editor_Sidebar' ); + scf_register_admin_beta_feature( 'SCF_Admin_Beta_Feature_Code_Patterns' ); } /** diff --git a/includes/admin/beta-features/class-scf-beta-feature-code-patterns.php b/includes/admin/beta-features/class-scf-beta-feature-code-patterns.php new file mode 100644 index 00000000..a0bb8016 --- /dev/null +++ b/includes/admin/beta-features/class-scf-beta-feature-code-patterns.php @@ -0,0 +1,37 @@ +name = 'code_patterns'; + $this->title = __( 'Add SCF Code Patterns', 'secure-custom-fields' ); + $this->description = __( 'Provides an API to register code patterns.', 'secure-custom-fields' ); + } + } +endif; diff --git a/includes/assets.php b/includes/assets.php index 5b166adb..ecddcdab 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -189,6 +189,14 @@ public function register_scripts() { 'version' => $version, 'in_footer' => true, ), + 'scf-bindings' => array( + 'handle' => 'scf-bindings', + 'src' => acf_get_url( sprintf( $js_path_patterns['base'], 'scf-bindings' ) ), + 'asset_file' => acf_get_path( sprintf( $asset_path_patterns['base'], 'scf-bindings' ) ), + 'version' => $version, + 'deps' => array(), + 'in_footer' => true, + ), ); // Define style registrations. @@ -539,6 +547,10 @@ public function enqueue_scripts() { do_action( 'acf/input/admin_enqueue_scripts' ); } + if ( get_option( 'scf_beta_feature_code_patterns_enabled' ) ) { + wp_enqueue_script( 'scf-bindings' ); + } + /** * Fires during "admin_enqueue_scripts" when ACF scripts are enqueued. * diff --git a/includes/forms/form-gutenberg.php b/includes/forms/form-gutenberg.php index 5acb081e..6749927b 100644 --- a/includes/forms/form-gutenberg.php +++ b/includes/forms/form-gutenberg.php @@ -69,7 +69,9 @@ function enqueue_block_editor_assets() { function add_meta_boxes() { // Remove 'edit_form_after_title' action. - remove_action( 'edit_form_after_title', array( acf_get_instance( 'ACF_Form_Post' ), 'edit_form_after_title' ) ); + if ( ! get_option( 'scf_beta_feature_code_patterns_enabled' ) ) { + remove_action( 'edit_form_after_title', array( acf_get_instance( 'ACF_Form_Post' ), 'edit_form_after_title' ) ); + } } /** diff --git a/secure-custom-fields.php b/secure-custom-fields.php index e94a1898..2ae913ab 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -169,6 +169,9 @@ public function initialize() { acf_include( 'includes/acf-field-group-functions.php' ); acf_include( 'includes/acf-form-functions.php' ); acf_include( 'includes/acf-meta-functions.php' ); + if ( get_option( 'scf_beta_feature_code_patterns_enabled' ) ) { + acf_include( 'includes/acf-pattern-functions.php' ); + } acf_include( 'includes/acf-post-functions.php' ); acf_include( 'includes/acf-user-functions.php' ); acf_include( 'includes/acf-value-functions.php' ); @@ -879,6 +882,7 @@ function scf_plugin_uninstall() { // List of known beta features. $beta_features = array( 'editor_sidebar', + 'code_patterns', ); foreach ( $beta_features as $beta_feature ) { diff --git a/webpack.config.js b/webpack.config.js index 699b7fe2..08f4ff7d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,8 +15,10 @@ const commonConfig = { 'js/acf-input': './assets/src/js/acf-input.js', 'js/acf-internal-post-type': './assets/src/js/acf-internal-post-type.js', + 'js/scf-bindings': './assets/src/js/bindings/index.js', 'js/commands/scf-admin': './assets/src/js/commands/admin-commands.js', - 'js/commands/scf-custom-post-types': './assets/src/js/commands/custom-post-type-commands.js', + 'js/commands/scf-custom-post-types': + './assets/src/js/commands/custom-post-type-commands.js', 'js/acf': './assets/src/js/acf.js', 'js/pro/acf-pro-blocks': './assets/src/js/pro/acf-pro-blocks.js', 'js/pro/acf-pro-field-group':