0, 'cacheHits' => 0 ]; public static function init() { if ( self::$initialized ) return; self::$initialized = true; self::register_core(); do_action( 'nyxonn_fx_register_dynamic_sources', __CLASS__ ); do_action( 'nyxonn_fx_dynamic_registry_ready', __CLASS__, self::API_VERSION ); } public static function register_provider( $id, $metadata = [] ) { $id = self::valid_id( $id ); if ( 'wordpress' === $id && ! self::$registering_core ) return false; if ( ! $id || isset( self::$providers[ $id ] ) || ! is_array( $metadata ) ) return false; self::$providers[ $id ] = [ 'id' => $id, 'label' => sanitize_text_field( $metadata['label'] ?? $id ), 'owner' => self::valid_id( $metadata['owner'] ?? 'extension' ) ?: 'extension', 'tier' => self::valid_id( $metadata['tier'] ?? 'free' ) ?: 'free', 'available' => ! isset( $metadata['available'] ) || (bool) $metadata['available'], 'version' => sanitize_text_field( $metadata['version'] ?? self::API_VERSION ), 'preview' => self::sanitize_options( $metadata['preview'] ?? [] ), ]; do_action( 'nyxonn_fx_dynamic_provider_registered', $id, self::public_provider( self::$providers[ $id ] ) ); return true; } public static function register_source( $provider, $id, $metadata, $resolver ) { $provider = self::valid_id( $provider ); $id = self::valid_id( $id ); if ( 'wordpress' === $provider && ! self::$registering_core ) return false; if ( ! $provider || ! $id || ! isset( self::$providers[ $provider ] ) || ! is_array( $metadata ) || ! is_callable( $resolver ) ) return false; $key = $provider . ':' . $id; if ( isset( self::$sources[ $key ] ) ) return false; $type = self::valid_id( $metadata['type'] ?? 'text' ); if ( ! in_array( $type, self::value_types(), true ) ) return false; $supports = isset( $metadata['supports'] ) && is_array( $metadata['supports'] ) ? $metadata['supports'] : [ $type ]; $supports = array_values( array_intersect( self::value_types(), array_filter( array_map( [ __CLASS__, 'valid_id' ], $supports ) ) ) ); self::$sources[ $key ] = [ 'provider' => $provider, 'id' => $id, 'label' => sanitize_text_field( $metadata['label'] ?? $id ), 'category' => strtoupper( sanitize_key( $metadata['category'] ?? 'post' ) ), 'type' => $type, 'supports' => $supports ?: [ $type ], 'resolver' => $resolver, 'capability' => isset( $metadata['capability_callback'] ) && is_callable( $metadata['capability_callback'] ) ? $metadata['capability_callback'] : null, 'preview' => self::sanitize_options( $metadata['preview'] ?? [] ), ]; do_action( 'nyxonn_fx_dynamic_source_registered', $key, self::public_source( self::$sources[ $key ] ) ); return true; } public static function get_public_registry() { self::init(); return [ 'providers' => array_values( array_map( [ __CLASS__, 'public_provider' ], self::$providers ) ), 'sources' => array_values( array_map( [ __CLASS__, 'public_source' ], self::$sources ) ), ]; } public static function value_types() { return [ 'text', 'rich_text', 'url', 'number', 'image', 'date' ]; } public static function field_contracts() { $core = [ 'heading' => [ 'content' => 'text', 'headingUrl' => 'url' ], 'text' => [ 'content' => 'rich_text' ], 'image' => [ 'url' => 'image', 'alt' => 'text', 'imageLink' => 'url' ], 'button' => [ 'text' => 'text', 'url' => 'url' ], 'iconbox' => [ 'iconBoxTitle' => 'text', 'iconBoxDescription' => 'rich_text', 'iconBoxLink' => 'url' ], ]; $filtered = apply_filters( 'nyxonn_fx_dynamic_field_contracts', $core ); if ( ! is_array( $filtered ) ) return $core; $contracts = $core; $allowed_types = self::value_types(); foreach ( $filtered as $module_type => $fields ) { if ( ! is_string( $module_type ) || ! preg_match( '/^[a-z][a-z0-9_-]{0,99}$/', $module_type ) || ! is_array( $fields ) ) continue; if ( ! isset( $contracts[ $module_type ] ) ) $contracts[ $module_type ] = []; foreach ( $fields as $field => $value_type ) { if ( ! is_string( $field ) || ! preg_match( '/^[A-Za-z][A-Za-z0-9_-]{0,99}$/', $field ) || ! in_array( $value_type, $allowed_types, true ) ) continue; // Built-in field types are stable contracts and cannot be replaced by extensions. if ( isset( $contracts[ $module_type ][ $field ] ) && $contracts[ $module_type ][ $field ] !== $value_type ) continue; $contracts[ $module_type ][ $field ] = $value_type; } } return $contracts; } public static function current_context( $post_id = 0 ) { // An explicitly supplied zero means "no authorized object". Only infer the // current request object when the caller omitted the context argument. $post_id = func_num_args() ? absint( $post_id ) : absint( get_queried_object_id() ?: get_the_ID() ); $post = $post_id ? get_post( $post_id ) : null; return [ 'postId' => $post ? (int) $post->ID : 0, 'postType' => $post ? sanitize_key( $post->post_type ) : '', 'authorId' => $post ? (int) $post->post_author : 0, 'isTemplate'=> $post && class_exists( Template_Manager::class ) && Template_Manager::TEMPLATES_POST_TYPE === $post->post_type, ]; } public static function builder_context( $editing_post_id ) { $editing_post_id = absint( $editing_post_id ); $editing = $editing_post_id ? get_post( $editing_post_id ) : null; $context_id = $editing_post_id; if ( $editing && class_exists( Template_Manager::class ) && Template_Manager::TEMPLATES_POST_TYPE === $editing->post_type ) { $context_id = absint( apply_filters( 'nyxonn_fx_dynamic_preview_post_id', 0, $editing_post_id ) ); if ( ! $context_id ) { $candidates = get_posts( [ 'post_type' => [ 'page', 'post' ], 'post_status' => 'publish', 'numberposts' => 10, 'orderby' => 'modified', 'order' => 'DESC' ] ); foreach ( $candidates as $candidate ) { if ( current_user_can( 'read_post', $candidate->ID ) ) { $context_id = (int) $candidate->ID; break; } } } } if ( $context_id && ! current_user_can( 'read_post', $context_id ) && ! current_user_can( 'edit_post', $context_id ) ) $context_id = 0; $context = self::current_context( $context_id ); $context['editingPostId'] = $editing_post_id; $context['previewPostId'] = $context_id; return $context; } public static function get_builder_payload( $editing_post_id ) { self::init(); $context = self::builder_context( $editing_post_id ); $sources = []; $values = []; foreach ( self::$sources as $key => $source ) { $provider = self::$providers[ $source['provider'] ]; $sources[] = [ 'provider' => $source['provider'], 'source' => $source['id'], 'label' => $source['label'], 'category' => $source['category'], 'type' => $source['type'], 'supports' => $source['supports'], 'owner' => $provider['owner'], 'tier' => $provider['tier'], 'available' => $provider['available'], 'preview' => $source['preview'], ]; if ( ! self::can_resolve( $source, $context ) ) { $values[ $key ] = 'image' === $source['type'] ? [] : ''; } elseif ( 'date' === $source['type'] ) { try { $timestamp = call_user_func( $source['resolver'], $context, [], [ 'provider' => $source['provider'], 'source' => $source['id'] ] ); $values[ $key ] = [ 'timestamp' => is_numeric( $timestamp ) ? (int) $timestamp : 0, 'formats' => [] ]; foreach ( [ 'site', 'F j, Y', 'Y-m-d', 'M j, Y', 'c' ] as $format ) $values[ $key ]['formats'][ $format ] = self::format_date( $timestamp, $format ); } catch ( \Throwable $error ) { $values[ $key ] = [ 'timestamp' => 0, 'formats' => [] ]; } } else { $values[ $key ] = self::resolve_descriptor( [ 'provider' => $source['provider'], 'source' => $source['id'] ], $source['type'], $context ); } } return [ 'apiVersion' => self::API_VERSION, 'types' => self::value_types(), 'fieldContracts' => self::field_contracts(), 'context' => $context, 'sources' => $sources, 'values' => $values, ]; } public static function resolve_module_settings( $module_type, $settings, $context = null ) { if ( ! is_array( $settings ) || empty( $settings['_dynamic'] ) || ! is_array( $settings['_dynamic'] ) ) return $settings; self::init(); $contracts = self::field_contracts(); $fields = isset( $contracts[ $module_type ] ) && is_array( $contracts[ $module_type ] ) ? $contracts[ $module_type ] : []; $context = is_array( $context ) ? $context : self::current_context(); foreach ( $fields as $field => $type ) { if ( empty( $settings['_dynamic'][ $field ] ) || ! is_array( $settings['_dynamic'][ $field ] ) ) continue; $value = self::resolve_descriptor( $settings['_dynamic'][ $field ], $type, $context ); if ( 'image' === $type ) { $image = is_array( $value ) ? $value : []; $settings[ $field ] = self::safe_url( $image['url'] ?? '' ); if ( $settings[ $field ] ) { $settings['imageId'] = absint( $image['id'] ?? 0 ); $settings['imageWidth'] = absint( $image['width'] ?? 0 ); $settings['imageHeight'] = absint( $image['height'] ?? 0 ); if ( empty( $settings['_dynamic']['alt'] ) ) $settings['alt'] = sanitize_text_field( $image['alt'] ?? '' ); } } else { $settings[ $field ] = $value; } } return $settings; } public static function resolve_descriptor( $descriptor, $expected_type = 'text', $context = null ) { $descriptor = self::sanitize_descriptor( $descriptor ); $expected_type = in_array( $expected_type, self::value_types(), true ) ? $expected_type : 'text'; if ( ! $descriptor ) return self::empty_value( $expected_type ); $fallback = $descriptor['fallback'] ?? ''; $provider = $descriptor['provider']; $key = $provider . ':' . $descriptor['source']; $source = self::$sources[ $key ] ?? null; if ( ! $source || empty( self::$providers[ $provider ]['available'] ) || ! self::is_compatible( $source, $expected_type ) ) return self::normalize( $fallback, $expected_type ); $context = is_array( $context ) ? $context : self::current_context(); if ( ! self::can_resolve( $source, $context ) ) return self::normalize( $fallback, $expected_type ); $stack_key = $key . ':' . absint( $context['postId'] ?? 0 ); if ( count( self::$stack ) >= self::MAX_DEPTH || in_array( $stack_key, self::$stack, true ) ) return self::normalize( $fallback, $expected_type ); $memo_key = md5( $stack_key . ':' . wp_json_encode( $descriptor['options'] ?? [] ) ); try { self::$stack[] = $stack_key; if ( array_key_exists( $memo_key, self::$memo ) ) { self::$stats['cacheHits']++; $raw = self::$memo[ $memo_key ]; } else { self::$stats['resolverCalls']++; $raw = call_user_func( $source['resolver'], $context, $descriptor['options'] ?? [], $descriptor ); self::$memo[ $memo_key ] = $raw; } array_pop( self::$stack ); } catch ( \Throwable $error ) { array_pop( self::$stack ); do_action( 'nyxonn_fx_dynamic_resolution_error', $error, $descriptor, $context ); return self::normalize( $fallback, $expected_type ); } if ( 'date' === $source['type'] && ! self::is_missing( $raw, 'date' ) ) $raw = self::format_date( $raw, $descriptor['dateFormat'] ?? 'site' ); if ( self::is_missing( $raw, $source['type'] ) ) $raw = $fallback; $value = self::normalize( $raw, $expected_type ); if ( in_array( $expected_type, [ 'text', 'rich_text', 'number', 'date' ], true ) && ! self::is_missing( $value, $expected_type ) ) { $value = (string) ( $descriptor['before'] ?? '' ) . $value . (string) ( $descriptor['after'] ?? '' ); } return $value; } public static function sanitize_descriptor( $descriptor ) { if ( ! is_array( $descriptor ) ) return null; $provider = self::valid_id( $descriptor['provider'] ?? '' ); $source = self::valid_id( $descriptor['source'] ?? '' ); if ( ! $provider || ! $source ) return null; $clean = self::sanitize_options( $descriptor ); $clean['dynamic'] = true; $clean['provider'] = $provider; $clean['source'] = $source; if ( isset( $clean['fallback'] ) ) $clean['fallback'] = wp_kses_post( (string) $clean['fallback'] ); foreach ( [ 'before', 'after' ] as $key ) if ( isset( $clean[ $key ] ) ) $clean[ $key ] = sanitize_text_field( (string) $clean[ $key ] ); if ( isset( $clean['dateFormat'] ) ) $clean['dateFormat'] = self::safe_date_format( $clean['dateFormat'] ); if ( isset( $clean['options'] ) ) $clean['options'] = self::sanitize_options( $clean['options'] ); return $clean; } public static function sanitize_map( $map ) { if ( ! is_array( $map ) ) return []; $clean = []; foreach ( $map as $field => $descriptor ) { $field = self::valid_field( $field ); $descriptor = self::sanitize_descriptor( $descriptor ); if ( $field && $descriptor ) $clean[ $field ] = $descriptor; } return $clean; } public static function sanitize_layout_descriptors( $value, $key = '' ) { if ( '_dynamic' === $key ) return self::sanitize_map( $value ); if ( ! is_array( $value ) ) return $value; $clean = []; foreach ( $value as $child_key => $child_value ) $clean[ $child_key ] = self::sanitize_layout_descriptors( $child_value, is_string( $child_key ) ? $child_key : '' ); return $clean; } public static function get_runtime_stats() { return self::$stats; } public static function reset_runtime_cache() { self::$memo = []; self::$stack = []; self::$stats = [ 'resolverCalls' => 0, 'cacheHits' => 0 ]; } private static function register_core() { if ( self::$registered ) return; self::$registered = true; self::$registering_core = true; self::register_provider( 'wordpress', [ 'label' => 'WordPress', 'owner' => 'core', 'tier' => 'free' ] ); self::register_source( 'wordpress', 'post_title', [ 'label' => 'Post/Page Title', 'category' => 'post', 'type' => 'text', 'supports' => [ 'text', 'rich_text' ] ], static function ( $c ) { $p = self::context_post( $c ); return $p ? get_the_title( $p ) : ''; } ); self::register_source( 'wordpress', 'post_excerpt', [ 'label' => 'Post/Page Excerpt', 'category' => 'post', 'type' => 'rich_text', 'supports' => [ 'text', 'rich_text' ] ], static function ( $c ) { $p = self::context_post( $c ); if ( ! $p ) return ''; return $p->post_excerpt ? $p->post_excerpt : wp_trim_words( wp_strip_all_tags( $p->post_content ), 55 ); } ); self::register_source( 'wordpress', 'post_content', [ 'label' => 'Post/Page Content', 'category' => 'post', 'type' => 'rich_text', 'supports' => [ 'rich_text', 'text' ] ], static function ( $c ) { $p = self::context_post( $c ); return $p ? $p->post_content : ''; } ); self::register_source( 'wordpress', 'featured_image', [ 'label' => 'Featured Image', 'category' => 'post', 'type' => 'image' ], static function ( $c ) { $p = self::context_post( $c ); if ( ! $p ) return null; $id = get_post_thumbnail_id( $p ); $src = $id ? wp_get_attachment_image_src( $id, 'full' ) : false; return $src ? [ 'id' => (int) $id, 'url' => $src[0], 'width' => (int) $src[1], 'height' => (int) $src[2], 'alt' => get_post_meta( $id, '_wp_attachment_image_alt', true ) ] : null; } ); self::register_source( 'wordpress', 'post_url', [ 'label' => 'Post/Page URL', 'category' => 'post', 'type' => 'url' ], static function ( $c ) { $p = self::context_post( $c ); return $p ? get_permalink( $p ) : ''; } ); self::register_source( 'wordpress', 'post_id', [ 'label' => 'Post/Page ID', 'category' => 'post', 'type' => 'number', 'supports' => [ 'number', 'text', 'rich_text' ] ], static function ( $c ) { return isset( $c['postId'] ) ? (int) $c['postId'] : null; } ); self::register_source( 'wordpress', 'post_date', [ 'label' => 'Post Date', 'category' => 'post', 'type' => 'date', 'supports' => [ 'date', 'text', 'rich_text' ] ], static function ( $c ) { $p = self::context_post( $c ); return $p ? get_post_time( 'U', false, $p ) : null; } ); self::register_source( 'wordpress', 'post_modified', [ 'label' => 'Post Modified Date', 'category' => 'post', 'type' => 'date', 'supports' => [ 'date', 'text', 'rich_text' ] ], static function ( $c ) { $p = self::context_post( $c ); return $p ? get_post_modified_time( 'U', false, $p ) : null; } ); self::register_source( 'wordpress', 'author_name', [ 'label' => 'Author Display Name', 'category' => 'author', 'type' => 'text', 'supports' => [ 'text', 'rich_text' ] ], static function ( $c ) { $u = get_userdata( absint( $c['authorId'] ?? 0 ) ); return $u ? $u->display_name : ''; } ); self::register_source( 'wordpress', 'author_url', [ 'label' => 'Author URL', 'category' => 'author', 'type' => 'url' ], static function ( $c ) { $id = absint( $c['authorId'] ?? 0 ); return $id ? get_author_posts_url( $id ) : ''; } ); self::register_source( 'wordpress', 'site_title', [ 'label' => 'Site Title', 'category' => 'site', 'type' => 'text', 'supports' => [ 'text', 'rich_text' ] ], static function () { return get_bloginfo( 'name' ); } ); self::register_source( 'wordpress', 'site_tagline', [ 'label' => 'Site Tagline', 'category' => 'site', 'type' => 'text', 'supports' => [ 'text', 'rich_text' ] ], static function () { return get_bloginfo( 'description' ); } ); self::register_source( 'wordpress', 'site_url', [ 'label' => 'Site URL', 'category' => 'site', 'type' => 'url' ], static function () { return home_url( '/' ); } ); self::$registering_core = false; } private static function context_post( $context ) { return ! empty( $context['postId'] ) ? get_post( absint( $context['postId'] ) ) : null; } private static function public_provider( $provider ) { return [ 'id'=>$provider['id'], 'label'=>$provider['label'], 'owner'=>$provider['owner'], 'tier'=>$provider['tier'], 'available'=>!empty($provider['available']), 'version'=>$provider['version'], 'preview'=>$provider['preview']??[] ]; } private static function public_source( $source ) { return [ 'provider'=>$source['provider'], 'id'=>$source['id'], 'label'=>$source['label'], 'category'=>$source['category'], 'type'=>$source['type'], 'supports'=>$source['supports'], 'preview'=>$source['preview']??[] ]; } private static function valid_id( $id ) { $raw = is_string( $id ) ? trim( $id ) : ''; return $raw && sanitize_key( $raw ) === $raw && preg_match( '/^[a-z][a-z0-9_-]*$/', $raw ) ? $raw : ''; } private static function valid_field( $field ) { $field = is_string( $field ) ? trim( $field ) : ''; return preg_match( '/^[A-Za-z][A-Za-z0-9_-]*$/', $field ) ? $field : ''; } private static function is_compatible( $source, $expected ) { return in_array( $expected, $source['supports'], true ); } private static function can_resolve( $source, $context ) { if ( empty( $source['capability'] ) ) return true; try { return (bool) call_user_func( $source['capability'], $context ); } catch ( \Throwable $error ) { return false; } } private static function is_missing( $value, $type ) { if ( 'image' === $type ) return ! is_array( $value ) || empty( $value['url'] ); return null === $value || '' === $value; } private static function empty_value( $type ) { return 'image' === $type ? [] : ''; } private static function safe_url( $value ) { return esc_url_raw( is_scalar( $value ) ? (string) $value : '', [ 'http', 'https', 'mailto', 'tel' ] ); } private static function normalize( $value, $type ) { if ( 'image' === $type ) { if ( is_string( $value ) ) $value = [ 'url' => $value ]; if ( ! is_array( $value ) ) return []; $url = self::safe_url( $value['url'] ?? '' ); return $url ? [ 'id' => absint( $value['id'] ?? 0 ), 'url' => $url, 'alt' => sanitize_text_field( $value['alt'] ?? '' ), 'width' => absint( $value['width'] ?? 0 ), 'height' => absint( $value['height'] ?? 0 ) ] : []; } if ( 'url' === $type ) return self::safe_url( $value ); if ( 'number' === $type ) return is_numeric( $value ) ? 0 + $value : ''; if ( 'rich_text' === $type ) return wp_kses_post( is_scalar( $value ) ? (string) $value : '' ); return sanitize_text_field( is_scalar( $value ) ? (string) $value : '' ); } private static function safe_date_format( $format ) { return in_array( $format, [ 'site', 'F j, Y', 'Y-m-d', 'M j, Y', 'c' ], true ) ? $format : 'site'; } private static function format_date( $value, $format ) { $timestamp = is_numeric( $value ) ? (int) $value : strtotime( (string) $value ); if ( ! $timestamp ) return ''; $format = self::safe_date_format( $format ); $format = 'site' === $format ? get_option( 'date_format' ) : $format; // wp_date() was introduced after the plugin's WordPress 5.0 minimum. return function_exists( 'wp_date' ) ? wp_date( $format, $timestamp ) : date_i18n( $format, $timestamp ); } private static function sanitize_options( $value ) { if ( is_array( $value ) ) { $clean = []; foreach ( $value as $key => $item ) { if ( is_string( $key ) && ( strlen( $key ) > 80 || ! preg_match( '/^[A-Za-z0-9_.-]+$/', $key ) ) ) continue; $clean[ $key ] = self::sanitize_options( $item ); } return $clean; } if ( is_string( $value ) ) return sanitize_text_field( $value ); return is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ? $value : null; } }