control_groups['duration'] = [
'title' => esc_html__('Total time', 'thinkrank'),
'tab' => 'content',
];
$this->control_groups['schema'] = [
'title' => esc_html__('Schema', 'thinkrank'),
'tab' => 'content',
];
}
/**
* Controls.
*
* @return void
*/
public function set_controls(): void {
$this->controls['heading'] = [
'label' => esc_html__('Title', 'thinkrank'),
'type' => 'text',
'placeholder' => esc_html__('How to …', 'thinkrank'),
];
$this->controls['headingTag'] = [
'label' => esc_html__('Heading tag', 'thinkrank'),
'type' => 'select',
'options' => [
'h2' => 'H2',
'h3' => 'H3',
'h4' => 'H4',
'p' => esc_html__('Paragraph', 'thinkrank'),
],
'default' => 'h2',
'inline' => true,
];
$this->controls['description'] = [
'label' => esc_html__('Description', 'thinkrank'),
'type' => 'textarea',
];
$this->controls['steps'] = [
'label' => esc_html__('Steps', 'thinkrank'),
'type' => 'repeater',
'titleProperty' => 'title',
'fields' => [
'title' => [
'label' => esc_html__('Step title', 'thinkrank'),
'type' => 'text',
],
'text' => [
'label' => esc_html__('Step description', 'thinkrank'),
'type' => 'editor',
],
'image' => [
'label' => esc_html__('Step image', 'thinkrank'),
'type' => 'image',
],
],
'default' => [
[
'title' => esc_html__('First step', 'thinkrank'),
'text' => esc_html__('Describe what to do here.', 'thinkrank'),
],
],
];
$this->controls['showNumbers'] = [
'label' => esc_html__('Numbered steps', 'thinkrank'),
'type' => 'checkbox',
'default' => true,
];
foreach (self::duration_labels() as $key => $label) {
$this->controls["total{$key}"] = [
'group' => 'duration',
'label' => $label,
'type' => 'number',
'min' => 0,
];
}
$this->controls['outputSchema'] = [
'group' => 'schema',
'label' => esc_html__('Output HowTo schema (JSON-LD)', 'thinkrank'),
'type' => 'checkbox',
'default' => true,
'description' => esc_html__('Adds HowTo structured data for rich results.', 'thinkrank'),
];
}
/**
* Render.
*
* @return void
*/
public function render(): void {
$settings = $this->settings;
$items = self::usable_steps($settings);
if (empty($items)) {
if (bricks_is_builder_call()) {
echo '
render_attributes('_root') . '>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. esc_html__('Add a step to get started.', 'thinkrank')
. '
';
}
return;
}
$this->set_attribute('_root', 'class', 'thinkrank-howto');
$heading_tag = self::heading_tag($settings);
$list_tag = empty($settings['showNumbers']) ? 'ul' : 'ol';
$output = 'render_attributes('_root') . '>';
$heading = trim((string) ($settings['heading'] ?? ''));
if ('' !== $heading) {
$output .= sprintf(
'<%1$s class="thinkrank-howto__heading">%2$s%1$s>',
esc_html($heading_tag),
esc_html($this->render_dynamic_data($heading))
);
}
$description = trim((string) ($settings['description'] ?? ''));
if ('' !== $description) {
$output .= '
'
. esc_html($this->render_dynamic_data($description)) . '
';
}
$duration = self::format_duration($settings);
if ('' !== $duration) {
$output .= '
'
. esc_html__('Total time:', 'thinkrank') . ' '
. esc_html($duration) . '
';
}
$output .= '<' . $list_tag . ' class="thinkrank-howto__steps">';
foreach ($items as $step) {
$output .= '
';
$title = trim((string) ($step['title'] ?? ''));
if ('' !== $title) {
$output .= ''
. esc_html($this->render_dynamic_data($title)) . '
';
}
$image = self::step_image($step);
if (null !== $image) {
$output .= sprintf(
'
',
esc_url($image['url']),
esc_attr($image['alt'])
);
}
$text = trim((string) ($step['text'] ?? ''));
if ('' !== $text) {
$output .= ''
. wp_kses_post(\Bricks\Helpers::parse_editor_content($this->render_dynamic_data($text)))
. '
';
}
$output .= '';
}
$output .= '' . $list_tag . '>
';
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->maybe_render_schema($settings, $items);
}
/**
* The repeater rows worth rendering.
*
* @since 2.3.1
* @param array $settings Element settings.
* @return array
*/
public static function usable_steps(array $settings): array {
$steps = is_array($settings['steps'] ?? null) ? $settings['steps'] : [];
return array_values(array_filter($steps, static function ($step) {
if (!is_array($step)) {
return false;
}
return !empty($step['title']) || !empty($step['text']) || null !== self::step_image($step);
}));
}
/**
* A step's image, resolved through the attachment when there is one.
*
* Bricks stores `{ id, url, ... }` for an image control. The id is
* authoritative: it carries the alt text, and it is how a deleted
* attachment degrades to nothing rather than to a dead URL — the same
* reasoning as the FAQ block's image handling (#418).
*
* @since 2.3.1
* @param array $step One repeater row.
* @return array{url:string,alt:string}|null
*/
private static function step_image(array $step): ?array {
$image = is_array($step['image'] ?? null) ? $step['image'] : [];
$id = (int) ($image['id'] ?? 0);
if ($id > 0) {
$url = wp_get_attachment_image_url($id, 'full');
if (is_string($url) && '' !== $url) {
return [
'url' => $url,
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
];
}
// The id names an attachment that is gone. A stored URL pointing
// into the same library is gone with it.
return null;
}
$url = trim((string) ($image['url'] ?? ''));
return '' !== $url ? ['url' => $url, 'alt' => ''] : null;
}
/**
* Human-readable total time, '' when unset.
*
* @param array $settings Element settings.
* @return string
*/
private static function format_duration(array $settings): string {
[$days, $hours, $minutes] = self::duration_parts($settings);
$parts = [];
if ($days > 0) {
/* translators: %d: number of days. */
$parts[] = sprintf(_n('%d day', '%d days', $days, 'thinkrank'), $days);
}
if ($hours > 0) {
/* translators: %d: number of hours. */
$parts[] = sprintf(_n('%d hour', '%d hours', $hours, 'thinkrank'), $hours);
}
if ($minutes > 0) {
/* translators: %d: number of minutes. */
$parts[] = sprintf(_n('%d minute', '%d minutes', $minutes, 'thinkrank'), $minutes);
}
return implode(', ', $parts);
}
/**
* Days, hours and minutes as non-negative ints.
*
* @param array $settings Element settings.
* @return array{0:int,1:int,2:int}
*/
private static function duration_parts(array $settings): array {
return [
max(0, (int) ($settings['totalDays'] ?? 0)),
max(0, (int) ($settings['totalHours'] ?? 0)),
max(0, (int) ($settings['totalMinutes'] ?? 0)),
];
}
/**
* Duration control keys and their labels.
*
* @return array
*/
private static function duration_labels(): array {
return [
'Days' => esc_html__('Days', 'thinkrank'),
'Hours' => esc_html__('Hours', 'thinkrank'),
'Minutes' => esc_html__('Minutes', 'thinkrank'),
];
}
/**
* Emit HowTo JSON-LD on the front end.
*
* @param array $settings Element settings.
* @param array $items Usable steps.
* @return void
*/
private function maybe_render_schema(array $settings, array $items): void {
if (empty($settings['outputSchema'])) {
return;
}
if (bricks_is_builder_call() || bricks_is_builder()) {
return;
}
$step_entities = [];
foreach ($items as $step) {
$title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
$text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
if ('' === $title && '' === $text) {
continue;
}
$entity = ['@type' => 'HowToStep'];
if ('' !== $title && '' !== $text) {
$entity['name'] = $title;
$entity['text'] = $text;
} else {
$entity['text'] = '' !== $text ? $text : $title;
}
$image = self::step_image($step);
if (null !== $image) {
$entity['image'] = [
'@type' => 'ImageObject',
'url' => esc_url_raw($image['url']),
];
}
$step_entities[] = $entity;
}
if (empty($step_entities)) {
return;
}
$heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
$schema = [
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => '' !== $heading ? $heading : (string) get_the_title(),
'step' => $step_entities,
];
$description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
if ('' !== $description) {
$schema['description'] = $description;
}
[$days, $hours, $minutes] = self::duration_parts($settings);
if ($days + $hours + $minutes > 0) {
$schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
}
$json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
if (false !== $json) {
echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* A heading tag from the allowed set.
*
* @param array $settings Element settings.
* @return string
*/
private static function heading_tag(array $settings): string {
$tag = (string) ($settings['headingTag'] ?? 'h2');
return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
}
}