registry = $registry; } /** * Render the full HTML for a report. * * @param array $config Per-site config. * @param array $context { * @type string $period_start ISO datetime. * @type string $period_end ISO datetime. * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026". * @type bool $is_test True when called from "Send Test Email". * @type bool $not_connected True to render the "connect Search Console" * state instead of the sections. * @type array $readiness Data_Provider::readiness() result. * } */ public function render(array $config, array $context): string { // Reset before walking the sections so sections_with_data() always // describes this render and never a previous one. $this->sections_with_data = 0; $shared_context = array_merge([ 'site_url' => (string) home_url(), 'frequency_days' => (int) ($config['frequency_days'] ?? 30), 'period_start' => '', 'period_end' => '', 'period_label' => '', 'is_test' => false, 'not_connected' => false, 'readiness' => [], 'shared' => [], ], $context); $sections_html = !empty($shared_context['not_connected']) ? $this->render_not_connected($shared_context) : $this->render_sections($config, $shared_context); $frequency_days = (int) $shared_context['frequency_days']; $payload = [ 'config' => $config, 'context' => $shared_context, 'sections_html' => $sections_html, 'header_logo' => $this->default_logo(), 'header_bg' => '', 'logo_link' => '', 'intro_text' => '', 'footer_text' => $this->default_footer($config), 'additional_css' => '', 'cta_url' => $this->dashboard_url(), 'settings_url' => Email_Report_Html::admin_link('analytics', 'email-reporting'), 'site_title' => (string) get_bloginfo('name'), 'site_url' => (string) home_url(), 'frequency_days' => $frequency_days, 'frequency_label' => $this->frequency_label($frequency_days), ]; /** * Filter the assembled payload before the layout template runs. * * The layout's presentation slots — header logo, logo link, header * background, intro, footer, extra CSS and the dashboard link — are * filled here. ThinkRank Pro fills them from its branding settings. * * @since 1.9.0 * * @param array $payload * @param array $config * @param array $shared_context */ $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context); // Tokens resolve after the filter, so text supplied through it gets // the same %site_title% / %period% substitution as the defaults. $payload['intro_text'] = $this->apply_text_tokens((string) ($payload['intro_text'] ?? ''), $shared_context); $payload['footer_text'] = $this->apply_text_tokens((string) ($payload['footer_text'] ?? ''), $shared_context); $layout = $this->locate_layout(); if (!is_readable($layout)) { return $this->emergency_fallback_html($payload); } ob_start(); // The layout file expects $payload in scope. include $layout; return (string) ob_get_clean(); } /** * Whether this config resolves to at least one section. * * A report with everything switched off still produced a valid email — * header, footer, and nothing in between — and reported it as a * successful send. Callers use this to skip the send instead. * * This is a *configuration* check: it answers "is anything enabled?", * not "did anything have data?". A section that is enabled but returns * nothing still counts here, because emptiness is only discovered later, * during render(). For the data question use sections_with_data() after * rendering. */ public function has_renderable_sections(array $config): bool { return $this->registry->resolve_for($config) !== []; } /** * Walk the resolved sections, render each, return concatenated HTML. */ private function render_sections(array $config, array $context): string { $sections = $this->registry->resolve_for($config); $html = ''; foreach ($sections as $section) { $section_html = $this->render_one_section($section, $context); if ($section_html === '') { continue; } $html .= $this->wrap_section($section, $section_html); } return $html; } private function render_one_section($section, array $context): string { try { $payload = $section->collect($context); if (empty($payload)) { return $this->fallback_for($section); } $rendered = $section->render($payload); if ($rendered === '') { return $this->fallback_for($section); } // A non-empty payload is not the same as data. Key Metrics // collects whenever the provider reports itself "available", // which it does even with no Search Console connection — and // then renders a row of zeroes. That is a placeholder, not a // report, so it must not make an empty send look non-empty. if (!$this->payload_has_data($section, $payload)) { return $this->fallback_for($section); } $this->sections_with_data++; return $rendered; } catch (Throwable $e) { // Don't let one section break the report. return $this->fallback_for($section); } } /** * What a section shows when it has nothing: its fallback_html(), which * the built-in sections leave empty so their card is dropped, and a * section written for 1.9 may still fill with a notice. */ private function fallback_for($section): string { try { return (string) $section->fallback_html(); } catch (Throwable $e) { return ''; } } /** * Whether a section's payload carries anything worth reporting. * * A section may override the default judgement by implementing * `has_data(array $payload): bool` — the interface does not require it, * so Pro sections with their own notion of emptiness can opt in without * every existing section having to change. * * The default is a structural read: any non-zero number, any non-empty * string, any true flag counts. An all-zero payload — six metrics at 0, * no keyword rows — does not. Sections that already return an empty * payload when they have nothing never reach this check. * * @param object $section */ private function payload_has_data($section, array $payload): bool { if (method_exists($section, 'has_data')) { return (bool) $section->has_data($payload); } return $this->array_has_value($payload); } private function array_has_value(array $values): bool { foreach ($values as $value) { if (is_array($value)) { if ($this->array_has_value($value)) { return true; } continue; } if (is_bool($value)) { if ($value) { return true; } continue; } if (is_int($value) || is_float($value)) { if (abs((float) $value) > 0.0) { return true; } continue; } if (is_string($value)) { if (trim($value) !== '') { return true; } continue; } if ($value !== null) { return true; } } return false; } /** * How many sections produced real data in the most recent render(). * * Zero means every section was dropped, so the report is a header and a * footer with nothing between. Callers use this to decide whether that * is worth sending. */ public function sections_with_data(): int { return $this->sections_with_data; } /** * One white card. A section that draws its own heading (the built-in * ones, via renders_own_heading()) gets the bare shell; any other gets * its label as the card title, so a section written for 1.9 still reads * as one card among the rest. */ private function wrap_section($section, string $body_html): string { $heading = ''; $own = method_exists($section, 'renders_own_heading') && $section->renders_own_heading(); if (!$own) { $heading = Email_Report_Html::heading((string) $section->label()) . '
| ' . $inner_html . ' |
| '
. ' '
. esc_html__('Connect Google Search Console to get your first report', 'thinkrank') . ' '
. ''
. esc_html(sprintf(
/* translators: %s: site title. */
__('This report is built from Search Console data: clicks, impressions, growing pages and queries, and where your keywords rank. %s isn’t connected yet, so there is nothing to report.', 'thinkrank'),
$site_title
)) . ' '
. ''
. '' . esc_html__('Scheduled reports are paused', 'thinkrank') . ' '
. esc_html__('until Search Console is connected. Nothing will be sent to your recipients in the meantime.', 'thinkrank') . ' '
. ''
. ' |
| ✓ | ' . '' . esc_html($item) . ' |
' . $title . ' |