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>
%1$s>', $wrapper_tag, esc_attr(''), $class_attr);
+ } else {
+ $inner_content = sprintf('<%1$s%3$s>%2$s%1$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%1$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':