diff --git a/inc/admin-pages/class-wizard-admin-page.php b/inc/admin-pages/class-wizard-admin-page.php index 6efaec51..a4935414 100644 --- a/inc/admin-pages/class-wizard-admin-page.php +++ b/inc/admin-pages/class-wizard-admin-page.php @@ -85,7 +85,7 @@ public function page_loaded() { /* * Sets current section for future reference. */ - $this->current_section = $sections[ $this->get_current_section() ]; + $this->current_section = $this->prepare_section_for_display($sections[ $this->get_current_section() ]); /* * Process save, if necessary @@ -203,7 +203,7 @@ public function output() { 'page' => $this, 'logo' => $this->get_logo(), 'labels' => $this->get_labels(), - 'sections' => $this->get_sections(), + 'sections' => $this->prepare_sections_for_display($this->get_sections()), 'current_section' => $this->get_current_section(), 'classes' => $this->get_classes(), 'clickable_navigation' => $this->clickable_navigation, @@ -212,6 +212,103 @@ public function output() { ); } + /** + * Resolves a single display value for wizard sections. + * + * @param mixed $value The raw value. + * @param bool $cast_to_bool Whether the resolved value should be cast to boolean. + * @return mixed + */ + protected function resolve_section_display_value($value, bool $cast_to_bool = false) { + + if (is_callable($value)) { + $value = call_user_func($value); + } + + if ($cast_to_bool) { + return (bool) $value; + } + + if (null === $value) { + return ''; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return ''; + } + + /** + * Resolves dynamic section values used for display while preserving callbacks + * responsible for handling the view, save routine, and field generation. + * + * @param array $section The raw section definition. + * @return array + */ + protected function prepare_section_for_display(array $section): array { + + $display_keys = [ + 'title', + 'description', + 'content', + 'next_label', + 'back_label', + 'skip_label', + ]; + + foreach ($display_keys as $display_key) { + if (array_key_exists($display_key, $section)) { + $section[ $display_key ] = $this->resolve_section_display_value($section[ $display_key ]); + } + } + + $boolean_keys = [ + 'disable_next', + 'back', + 'skip', + 'next', + ]; + + foreach ($boolean_keys as $boolean_key) { + if (array_key_exists($boolean_key, $section)) { + $section[ $boolean_key ] = $this->resolve_section_display_value($section[ $boolean_key ], true); + } + } + + if ( ! empty($section['sub-sections']) && is_array($section['sub-sections'])) { + foreach ($section['sub-sections'] as $sub_section_key => $sub_section) { + if (is_array($sub_section)) { + $section['sub-sections'][ $sub_section_key ] = $this->prepare_section_for_display($sub_section); + } + } + } + + return $section; + } + + /** + * Resolves the display values of all wizard sections. + * + * @param array $sections Raw wizard sections. + * @return array + */ + protected function prepare_sections_for_display(array $sections): array { + + foreach ($sections as $section_key => $section) { + if (is_array($section)) { + $sections[ $section_key ] = $this->prepare_section_for_display($section); + } + } + + return $sections; + } + /** * Return the classes used in the main wrapper. * diff --git a/inc/class-credits.php b/inc/class-credits.php index 8a2af473..097e7dbb 100644 --- a/inc/class-credits.php +++ b/inc/class-credits.php @@ -100,15 +100,15 @@ public function register_settings(): void { 'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'), 'type' => 'textarea', 'allow_html' => true, - 'default' => function () { - $name = (string) get_network_option(null, 'site_name'); - $name = $name ?: __('this network', 'ultimate-multisite'); - $url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); - return sprintf( - /* translators: 1: Opening anchor tag with URL to main site. 2: Network name. */ - __('Powered by %1$s%2$s', 'ultimate-multisite'), - '', - esc_html($name) + 'default' => [$this, 'get_default_custom_credit_html'], + 'value' => function () { + return $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) + ); + }, + 'display_value' => function () { + return $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) ); }, 'placeholder' => __('Powered by Your Company', 'ultimate-multisite'), @@ -121,6 +121,39 @@ public function register_settings(): void { ); } + /** + * Normalizes a stored custom credit HTML value. + * + * Returns the default credit HTML when the stored value is the literal + * string '[object Object]', which can appear when a Closure leaked into + * the Vue/settings JSON state before this bug was fixed. + * + * @param mixed $html The raw stored value. + * @return string + */ + protected function normalize_custom_credit_html($html): string { + $html = is_string($html) ? $html : (string) $html; + + return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; + } + + /** + * Returns the default custom credit HTML. + */ + protected function get_default_custom_credit_html(): string { + $name = (string) get_network_option(null, 'site_name'); + $name = $name ?: __('this network', 'ultimate-multisite'); + $url = is_multisite() ? get_site_url(get_main_site_id()) : network_home_url('/'); + + return sprintf( + /* translators: 1: Opening anchor tag, 2: Network name, 3: Closing anchor tag. */ + __('Powered by %1$s%2$s%3$s', 'ultimate-multisite'), + '', + esc_html($name), + '' + ); + } + /** * Build the credit text (HTML) based on settings. */ @@ -137,7 +170,9 @@ protected function build_credit_html(): string { return $this->build_custom_credit(); case 'html': - $html = (string) wu_get_setting('credits_custom_html', ''); + $html = $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) + ); return wp_kses_post($html); default: @@ -170,7 +205,7 @@ protected function build_custom_credit(): string { $logo_html = $this->get_company_logo_html(); $network_name = (string) get_network_option(null, 'site_name'); $network_name = $network_name ?: __('this network', 'ultimate-multisite'); - $network_url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + $network_url = is_multisite() ? get_site_url(get_main_site_id()) : network_home_url('/'); $text = sprintf( '%s', diff --git a/inc/class-settings.php b/inc/class-settings.php index 5e53efbd..08224341 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -515,9 +515,17 @@ function ($fields) use ($field_slug, $atts) { */ $declared_default = $atts['default'] ?? null; + $get_resolved_default = static function () use ($declared_default) { + return is_callable($declared_default) ? call_user_func($declared_default) : $declared_default; + }; + if (null === $declared_default) { $type = $atts['type'] ?? 'text'; $declared_default = in_array($type, ['toggle', 'checkbox'], true) ? false : ''; + + $get_resolved_default = static function () use ($declared_default) { + return $declared_default; + }; } $atts = wp_parse_args( @@ -532,8 +540,8 @@ function ($fields) use ($field_slug, $atts) { 'wrapper_html_attr' => [], 'require' => [], 'html_attr' => [], - 'value' => fn() => wu_get_setting($field_slug, $declared_default), - 'display_value' => fn() => wu_get_setting($field_slug, $declared_default), + 'value' => fn() => wu_get_setting($field_slug, $get_resolved_default()), + 'display_value' => fn() => wu_get_setting($field_slug, $get_resolved_default()), 'img' => function () use ($field_slug) { $img_id = wu_get_setting($field_slug); diff --git a/inc/functions/template.php b/inc/functions/template.php index 0cbb213b..9e6c9926 100644 --- a/inc/functions/template.php +++ b/inc/functions/template.php @@ -89,3 +89,24 @@ function wu_get_template_contents($view, $args = [], $default_view = false) { return ob_get_clean(); } + +/** + * Resolves template values to safe strings for rendering. + * + * @param mixed $value The raw value. + * @param mixed $context Optional context passed when invoking callables. + * @return string + */ +function wu_resolve_template_string($value, $context = null): string { + + if (is_callable($value)) { + $value = null !== $context ? call_user_func($value, $context) : call_user_func($value); + } + + if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { + return (string) $value; + } + + return ''; +} + diff --git a/inc/ui/class-field.php b/inc/ui/class-field.php index aa0de9e1..4efaa741 100644 --- a/inc/ui/class-field.php +++ b/inc/ui/class-field.php @@ -277,21 +277,33 @@ public function __get($att) { 'require', 'validation', 'value', + 'placeholder', + 'classes', + 'wrapper_classes', 'html_attr', + 'wrapper_html_attr', + 'prefix_html_attr', + 'suffix_html_attr', + 'prefix', + 'suffix', + 'button', + 'href', 'img', ]; $attr = $this->atts[ $att ] ?? false; - $allow_callable_prefix = is_string($attr) && str_starts_with($attr, 'wu_get_') && is_callable($attr); - $allow_callable_method = is_array($attr) && is_callable($attr); - - if (in_array($att, $allowed_callable, true) && ($allow_callable_prefix || $allow_callable_method || is_a($attr, \Closure::class))) { - $attr = call_user_func($attr, $this); + if (in_array($att, $allowed_callable, true)) { + $attr = $this->resolve_attribute_value($attr); } - if ('wrapper_classes' === $att && isset($this->atts['wrapper_html_attr']['v-show'])) { - $this->atts['wrapper_classes'] .= ' wu-requires-other'; + if ('wrapper_classes' === $att) { + $attr = is_string($attr) ? $attr : ''; + $wrapper_html_attr = $this->resolve_attribute_value($this->atts['wrapper_html_attr'] ?? []); + + if (is_array($wrapper_html_attr) && isset($wrapper_html_attr['v-show']) && ! str_contains($attr, 'wu-requires-other')) { + $attr .= ' wu-requires-other'; + } } if ('type' === $att && 'submit' === $this->atts[ $att ]) { @@ -303,11 +315,13 @@ public function __get($att) { } if ('wrapper_classes' === $att && is_a($this->form, '\\WP_Ultimo\\UI\\Form')) { - return $this->form->field_wrapper_classes . ' ' . $this->atts['wrapper_classes']; + return trim($this->form->field_wrapper_classes . ' ' . $attr); } if ('classes' === $att && is_a($this->form, '\\WP_Ultimo\\UI\\Form')) { - return $this->form->field_classes . ' ' . $this->atts['classes']; + $attr = is_string($attr) ? $attr : ''; + + return trim($this->form->field_classes . ' ' . $attr); } if ('title' === $att && false === $attr && isset($this->atts['name'])) { @@ -317,6 +331,50 @@ public function __get($att) { return $attr; } + /** + * Checks if the given attribute value should be resolved as a callable. + * + * @param mixed $attr The attribute value. + * @return bool + */ + protected function is_resolvable_callable($attr): bool { + + $allow_callable_prefix = is_string($attr) && str_starts_with($attr, 'wu_get_') && is_callable($attr); + $allow_callable_method = is_array($attr) && is_callable($attr); + + return $allow_callable_prefix || $allow_callable_method || is_a($attr, \Closure::class); + } + + /** + * Resolves dynamic attribute values and nested callable entries. + * + * Callable values are first validated by is_resolvable_callable() and, + * when invoked, receive the current Field instance as their first argument. + * + * @param mixed $attr The attribute value. + * @return mixed + */ + protected function resolve_attribute_value($attr) { + + if ($this->is_resolvable_callable($attr)) { + $attr = call_user_func($attr, $this); + } + + if (is_array($attr)) { + foreach ($attr as $key => $value) { + $attr[ $key ] = $this->resolve_attribute_value($value); + } + + return $attr; + } + + if (is_object($attr) && method_exists($attr, '__toString')) { + return (string) $attr; + } + + return $attr; + } + /** * Returns the list of sanitization callbacks for each field type * @@ -457,9 +515,7 @@ protected function validate_textarea_field($value) { */ public function print_html_attributes(): void { - if (is_callable($this->atts['html_attr'])) { - $this->atts['html_attr'] = call_user_func($this->atts['html_attr']); - } + $this->atts['html_attr'] = $this->resolve_attribute_value($this->atts['html_attr']); unset($this->atts['html_attr']['class']); $attributes = $this->atts['html_attr']; @@ -492,6 +548,8 @@ public function print_html_attributes(): void { */ public function print_wrapper_html_attributes(): void { + $this->atts['wrapper_html_attr'] = $this->resolve_attribute_value($this->atts['wrapper_html_attr']); + $attributes = $this->atts['wrapper_html_attr']; unset($this->atts['wrapper_html_attr']['class']); diff --git a/views/admin-pages/fields/field-textarea.php b/views/admin-pages/fields/field-textarea.php index 7f2a543a..330badaa 100644 --- a/views/admin-pages/fields/field-textarea.php +++ b/views/admin-pages/fields/field-textarea.php @@ -6,8 +6,19 @@ */ defined('ABSPATH') || exit; +$field = $field ?? null; + +if ( ! $field instanceof \WP_Ultimo\UI\Field) { + return; +} + +$wrapper_classes = wu_resolve_template_string($field->wrapper_classes, $field); +$field_classes = wu_resolve_template_string($field->classes, $field); +$placeholder = wu_resolve_template_string($field->placeholder, $field); +$value = wu_resolve_template_string($field->value, $field); + ?> -
  • print_wrapper_html_attributes(); ?>> +
  • print_wrapper_html_attributes(); ?>>
    @@ -27,7 +38,7 @@ ?> - +

    - +

    - +

    - []]); ?> + []]); ?>

    - +