Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
<exclude-pattern>/vendor/</exclude-pattern>
<exclude-pattern>/node_modules/</exclude-pattern>
<exclude-pattern>/lang/*</exclude-pattern>
<exclude-pattern>/includes/acf-pattern-functions.php</exclude-pattern>

<!-- How to scan -->
<arg value="sp"/>
192 changes: 192 additions & 0 deletions assets/src/js/bindings/custom-sources.js
Original file line number Diff line number Diff line change
@@ -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;
},
} );
1 change: 1 addition & 0 deletions assets/src/js/bindings/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './custom-sources.js';
66 changes: 65 additions & 1 deletion includes/Blocks/Bindings.php
Original file line number Diff line number Diff line change
@@ -31,14 +31,78 @@ 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(
'label' => _x( 'SCF Fields', 'The core SCF block binding source name for fields on the current page', 'secure-custom-fields' ),
'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 : '';
}
}

5 changes: 5 additions & 0 deletions includes/acf-form-functions.php
Original file line number Diff line number Diff line change
@@ -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 );

247 changes: 247 additions & 0 deletions includes/acf-pattern-functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<?php
/**
* Functions for registering and managing ACF block patterns.
*
* @package SecureCustomFields
*/

/**
* Smart Pattern Registration with PHP-to-Binding Conversion
*
* @since SCF 6.5.0
* @param string $pattern_directory The directory containing the pattern file.
* @return bool|WP_Error The pattern registration result or a WP_Error if the pattern is invalid.
*/
function experimental_scf_register_pattern( $pattern_directory ) {
if ( ! file_exists( $pattern_directory ) || ! is_readable( $pattern_directory ) ) {
return new WP_Error( 'pattern_not_found', 'Pattern file not found' );
}

$headers = array(
'title' => '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><img src="#%2$s" alt="%2$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(
'<!-- wp:%s %s -->
%s
<!-- /wp:%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('/^<!-- wp:core\/group|^<!-- wp:group/', trim($pattern_content))) {
$pattern_content = sprintf(
"<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group\">%s</div>\n<!-- /wp:group -->",
$pattern_content
);
}

return $pattern_content;
}
10 changes: 5 additions & 5 deletions includes/admin/beta-features.php
Original file line number Diff line number Diff line change
@@ -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' );
}

/**
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php
/**
* Editor Sidebar Beta Feature
*
* This beta feature allows moving field group elements to the editor sidebar.
*
* @package Secure Custom Fields
* @since SCF 6.5.0
*/

if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}

if ( ! class_exists( 'SCF_Admin_Beta_Feature_Code_Patterns' ) ) :
/**
* Class SCF_Admin_Beta_Feature_Code_Patterns
*
* Implements a beta feature for managing code patterns.
*
* @package Secure Custom Fields
* @since SCF 6.5.0
*/
class SCF_Admin_Beta_Feature_Code_Patterns extends SCF_Admin_Beta_Feature {

/**
* Initialize the beta feature.
*
* @return void
*/
protected function initialize() {
$this->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;
12 changes: 12 additions & 0 deletions includes/assets.php
Original file line number Diff line number Diff line change
@@ -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.
*
4 changes: 3 additions & 1 deletion includes/forms/form-gutenberg.php
Original file line number Diff line number Diff line change
@@ -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' ) );
}
}

/**
4 changes: 4 additions & 0 deletions secure-custom-fields.php
Original file line number Diff line number Diff line change
@@ -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 ) {
4 changes: 3 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
@@ -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':