Skip to content
101 changes: 99 additions & 2 deletions inc/admin-pages/class-wizard-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
*
Expand Down
57 changes: 46 additions & 11 deletions inc/class-credits.php
Original file line number Diff line number Diff line change
Expand Up @@ -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</a>', 'ultimate-multisite'),
'<a href="' . esc_url($url) . '" target="_blank">',
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())
);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'placeholder' => __('Powered by <a href="https://example.com">Your Company</a>', 'ultimate-multisite'),
Expand All @@ -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'),
'<a href="' . esc_url($url) . '" target="_blank">',
esc_html($name),
'</a>'
);
}

/**
* Build the credit text (HTML) based on settings.
*/
Expand All @@ -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:
Expand Down Expand Up @@ -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(
'<a href="%s" target="_blank">%s</a>',
Expand Down
12 changes: 10 additions & 2 deletions inc/class-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions inc/functions/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}

82 changes: 70 additions & 12 deletions inc/ui/class-field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if ('type' === $att && 'submit' === $this->atts[ $att ]) {
Expand All @@ -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'])) {
Expand All @@ -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
*
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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']);
Expand Down
Loading