PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-email-report-renderer.php +311 -60 1.28.0 → 2.9.0 View file →
@@ -2,13 +2,16 @@
2 2 /**
3 3 * Email Report Renderer
4 4 *
5 5 * Walks the registered sections, calls collect() then render() on each,
6 - * and assembles the final HTML using templates/email-report/email.html.php.
6 + * wraps each result in a card and assembles the final HTML using
7 + * templates/email-report/email.html.php.
7 8 *
8 - * Failure model: any single section that throws or returns empty drops to
9 - * its fallback_html() — the whole report keeps rendering. Per PRD's
10 - * "graceful degradation" acceptance criterion.
9 + * Failure model: a section that throws, collects nothing or renders to
10 + * nothing is dropped — the card is simply absent — and the rest of the
11 + * report keeps rendering. A section may still supply fallback_html(); when
12 + * it is non-empty it is shown in the section's card, which keeps sections
13 + * written against 1.9 working unchanged (#742).
11 14 *
12 15 * @package ThinkRank
13 16 * @subpackage SEO
14 17 * @since 1.9.0
@@ -17,9 +20,9 @@
17 20 declare(strict_types=1);
18 21
19 22 namespace ThinkRank\SEO;
20 23
21 -use ThinkRank\Core\Plan_Config;
24 +use ThinkRank\SEO\Email_Report_Sections\Email_Report_Html;
22 25 use Throwable;
23 26
24 27 if (!defined('ABSPATH')) {
25 28 exit;
@@ -33,8 +36,18 @@
33 36 final class Email_Report_Renderer {
34 37
35 38 private Email_Report_Section_Registry $registry;
36 39
40 + /**
41 + * How many sections produced real data during the last render().
42 + *
43 + * A section that returns an empty payload (or renders to nothing) is
44 + * dropped. That outcome is only known *inside* render_one_section(),
45 + * so it is counted here for the caller to read back after rendering —
46 + * see sections_with_data().
47 + */
48 + private int $sections_with_data = 0;
49 +
37 50 public function __construct(Email_Report_Section_Registry $registry) {
38 51 $this->registry = $registry;
39 52 }
40 53
@@ -42,16 +55,21 @@
42 55 * Render the full HTML for a report.
43 56 *
44 57 * @param array $config Per-site config.
45 58 * @param array $context {
46 - * @type string $period_start ISO datetime.
47 - * @type string $period_end ISO datetime.
48 - * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026".
49 - * @type bool $is_test True when called from "Send Test Email".
59 + * @type string $period_start ISO datetime.
60 + * @type string $period_end ISO datetime.
61 + * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026".
62 + * @type bool $is_test True when called from "Send Test Email".
63 + * @type bool $not_connected True to render the "connect Search Console"
64 + * state instead of the sections.
65 + * @type array $readiness Data_Provider::readiness() result.
50 66 * }
51 67 */
52 68 public function render(array $config, array $context): string {
53 - $caps = Plan_Config::email_report();
69 + // Reset before walking the sections so sections_with_data() always
70 + // describes this render and never a previous one.
71 + $this->sections_with_data = 0;
54 72
55 73 $shared_context = array_merge([
56 74 'site_url' => (string) home_url(),
57 75 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
@@ -58,35 +76,43 @@
58 76 'period_start' => '',
59 77 'period_end' => '',
60 78 'period_label' => '',
61 79 'is_test' => false,
80 + 'not_connected' => false,
81 + 'readiness' => [],
62 82 'shared' => [],
63 83 ], $context);
64 84
65 - $sections_html = $this->render_sections($config, $shared_context);
85 + $sections_html = !empty($shared_context['not_connected'])
86 + ? $this->render_not_connected($shared_context)
87 + : $this->render_sections($config, $shared_context);
66 88
67 - $intro_text = empty($caps['intro_text']) ? '' : (string) ($config['intro_text'] ?? '');
68 - $footer_text = empty($caps['footer_text']) ? $this->default_footer() : (string) ($config['footer_text'] ?? $this->default_footer());
89 + $frequency_days = (int) $shared_context['frequency_days'];
69 90
70 91 $payload = [
71 - 'config' => $config,
72 - 'caps' => $caps,
73 - 'context' => $shared_context,
74 - 'sections_html' => $sections_html,
75 - 'header_logo' => $this->resolve_logo($config, $caps),
76 - 'header_bg' => empty($caps['header_background']) ? '' : (string) ($config['header_background'] ?? ''),
77 - 'logo_link' => empty($caps['logo_link']) ? '' : (string) ($config['logo_link'] ?? ''),
78 - 'intro_text' => $this->apply_text_tokens($intro_text, $shared_context),
79 - 'footer_text' => $this->apply_text_tokens($footer_text, $shared_context),
80 - 'additional_css' => empty($caps['additional_css']) ? '' : (string) ($config['additional_css'] ?? ''),
81 - 'cta_url' => $this->resolve_cta_url($config, $caps),
82 - 'site_title' => (string) get_bloginfo('name'),
92 + 'config' => $config,
93 + 'context' => $shared_context,
94 + 'sections_html' => $sections_html,
95 + 'header_logo' => $this->default_logo(),
96 + 'header_bg' => '',
97 + 'logo_link' => '',
98 + 'intro_text' => '',
99 + 'footer_text' => $this->default_footer($config),
100 + 'additional_css' => '',
101 + 'cta_url' => $this->dashboard_url(),
102 + 'settings_url' => Email_Report_Html::admin_link('analytics', 'email-reporting'),
103 + 'site_title' => (string) get_bloginfo('name'),
104 + 'site_url' => (string) home_url(),
105 + 'frequency_days' => $frequency_days,
106 + 'frequency_label' => $this->frequency_label($frequency_days),
83 107 ];
84 108
85 109 /**
86 110 * Filter the assembled payload before the layout template runs.
87 111 *
88 - * Pro can rewrite logo/header/footer here without touching the renderer.
112 + * The layout's presentation slots — header logo, logo link, header
113 + * background, intro, footer, extra CSS and the dashboard link — are
114 + * filled here. ThinkRank Pro fills them from its branding settings.
89 115 *
90 116 * @since 1.9.0
91 117 *
92 118 * @param array $payload
@@ -94,8 +120,13 @@
94 120 * @param array $shared_context
95 121 */
96 122 $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context);
97 123
124 + // Tokens resolve after the filter, so text supplied through it gets
125 + // the same %site_title% / %period% substitution as the defaults.
126 + $payload['intro_text'] = $this->apply_text_tokens((string) ($payload['intro_text'] ?? ''), $shared_context);
127 + $payload['footer_text'] = $this->apply_text_tokens((string) ($payload['footer_text'] ?? ''), $shared_context);
128 +
98 129 $layout = $this->locate_layout();
99 130 if (!is_readable($layout)) {
100 131 return $this->emergency_fallback_html($payload);
101 132 }
@@ -106,8 +137,25 @@
106 137 return (string) ob_get_clean();
107 138 }
108 139
109 140 /**
141 + * Whether this config resolves to at least one section.
142 + *
143 + * A report with everything switched off still produced a valid email —
144 + * header, footer, and nothing in between — and reported it as a
145 + * successful send. Callers use this to skip the send instead.
146 + *
147 + * This is a *configuration* check: it answers "is anything enabled?",
148 + * not "did anything have data?". A section that is enabled but returns
149 + * nothing still counts here, because emptiness is only discovered later,
150 + * during render(). For the data question use sections_with_data() after
151 + * rendering.
152 + */
153 + public function has_renderable_sections(array $config): bool {
154 + return $this->registry->resolve_for($config) !== [];
155 + }
156 +
157 + /**
110 158 * Walk the resolved sections, render each, return concatenated HTML.
111 159 */
112 160 private function render_sections(array $config, array $context): string {
113 161 $sections = $this->registry->resolve_for($config);
@@ -114,8 +162,11 @@
114 162 $html = '';
115 163
116 164 foreach ($sections as $section) {
117 165 $section_html = $this->render_one_section($section, $context);
166 + if ($section_html === '') {
167 + continue;
168 + }
118 169 $html .= $this->wrap_section($section, $section_html);
119 170 }
120 171
121 172 return $html;
@@ -124,56 +175,230 @@
124 175 private function render_one_section($section, array $context): string {
125 176 try {
126 177 $payload = $section->collect($context);
127 178 if (empty($payload)) {
128 - return $section->fallback_html();
179 + return $this->fallback_for($section);
129 180 }
130 181 $rendered = $section->render($payload);
131 - return $rendered !== '' ? $rendered : $section->fallback_html();
182 + if ($rendered === '') {
183 + return $this->fallback_for($section);
184 + }
185 +
186 + // A non-empty payload is not the same as data. Key Metrics
187 + // collects whenever the provider reports itself "available",
188 + // which it does even with no Search Console connection — and
189 + // then renders a row of zeroes. That is a placeholder, not a
190 + // report, so it must not make an empty send look non-empty.
191 + if (!$this->payload_has_data($section, $payload)) {
192 + return $this->fallback_for($section);
193 + }
194 +
195 + $this->sections_with_data++;
196 +
197 + return $rendered;
132 198 } catch (Throwable $e) {
133 199 // Don't let one section break the report.
134 - return $section->fallback_html();
200 + return $this->fallback_for($section);
135 201 }
136 202 }
137 203
204 + /**
205 + * What a section shows when it has nothing: its fallback_html(), which
206 + * the built-in sections leave empty so their card is dropped, and a
207 + * section written for 1.9 may still fill with a notice.
208 + */
209 + private function fallback_for($section): string {
210 + try {
211 + return (string) $section->fallback_html();
212 + } catch (Throwable $e) {
213 + return '';
214 + }
215 + }
216 +
217 + /**
218 + * Whether a section's payload carries anything worth reporting.
219 + *
220 + * A section may override the default judgement by implementing
221 + * `has_data(array $payload): bool` — the interface does not require it,
222 + * so Pro sections with their own notion of emptiness can opt in without
223 + * every existing section having to change.
224 + *
225 + * The default is a structural read: any non-zero number, any non-empty
226 + * string, any true flag counts. An all-zero payload — six metrics at 0,
227 + * no keyword rows — does not. Sections that already return an empty
228 + * payload when they have nothing never reach this check.
229 + *
230 + * @param object $section
231 + */
232 + private function payload_has_data($section, array $payload): bool {
233 + if (method_exists($section, 'has_data')) {
234 + return (bool) $section->has_data($payload);
235 + }
236 +
237 + return $this->array_has_value($payload);
238 + }
239 +
240 + private function array_has_value(array $values): bool {
241 + foreach ($values as $value) {
242 + if (is_array($value)) {
243 + if ($this->array_has_value($value)) {
244 + return true;
245 + }
246 + continue;
247 + }
248 + if (is_bool($value)) {
249 + if ($value) {
250 + return true;
251 + }
252 + continue;
253 + }
254 + if (is_int($value) || is_float($value)) {
255 + if (abs((float) $value) > 0.0) {
256 + return true;
257 + }
258 + continue;
259 + }
260 + if (is_string($value)) {
261 + if (trim($value) !== '') {
262 + return true;
263 + }
264 + continue;
265 + }
266 + if ($value !== null) {
267 + return true;
268 + }
269 + }
270 +
271 + return false;
272 + }
273 +
274 + /**
275 + * How many sections produced real data in the most recent render().
276 + *
277 + * Zero means every section was dropped, so the report is a header and a
278 + * footer with nothing between. Callers use this to decide whether that
279 + * is worth sending.
280 + */
281 + public function sections_with_data(): int {
282 + return $this->sections_with_data;
283 + }
284 +
285 + /**
286 + * One white card. A section that draws its own heading (the built-in
287 + * ones, via renders_own_heading()) gets the bare shell; any other gets
288 + * its label as the card title, so a section written for 1.9 still reads
289 + * as one card among the rest.
290 + */
138 291 private function wrap_section($section, string $body_html): string {
139 - $heading = esc_html($section->label());
140 - return '<section class="tr-email-section" style="margin:0 0 24px 0;">'
141 - . '<h2 style="font:600 18px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;margin:0 0 12px 0;color:#111827;">'
142 - . $heading
143 - . '</h2>'
144 - . $body_html
145 - . '</section>';
292 + $heading = '';
293 + $own = method_exists($section, 'renders_own_heading') && $section->renders_own_heading();
294 + if (!$own) {
295 + $heading = Email_Report_Html::heading((string) $section->label()) . '<div style="height:14px;line-height:14px;font-size:0;">&nbsp;</div>';
296 + }
297 +
298 + return self::card($heading . $body_html, 'tr-email-section tr-email-section-' . sanitize_html_class((string) $section->key()));
146 299 }
147 300
148 - private function resolve_logo(array $config, array $caps): string {
149 - if (!empty($caps['custom_logo']) && !empty($config['logo_url'])) {
150 - return (string) $config['logo_url'];
301 + /**
302 + * The card shell used by sections, the connect state and the layout.
303 + */
304 + public static function card(string $inner_html, string $css_class = 'tr-email-section', string $padding = '24px 28px'): string {
305 + return '<tr><td class="' . esc_attr($css_class) . '" style="padding:0 0 16px 0;">'
306 + . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid ' . Email_Report_Html::LINE . ';border-radius:14px;">'
307 + . '<tr><td style="padding:' . esc_attr($padding) . ';">' . $inner_html . '</td></tr>'
308 + . '</table></td></tr>';
309 + }
310 +
311 + /**
312 + * The one-card email a test send delivers while Search Console is not
313 + * connected: what the report will contain, how to connect, and that
314 + * scheduled reports are paused until then (#742).
315 + */
316 + private function render_not_connected(array $context): string {
317 + $font = Email_Report_Html::FONT;
318 + $site_title = (string) get_bloginfo('name');
319 + $connect = Email_Report_Html::admin_link('integrations', 'google-services');
320 + $settings = Email_Report_Html::admin_link('analytics', 'email-reporting');
321 +
322 + $intro = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
323 + . '<td width="56" valign="top"><img src="' . esc_url(Email_Report_Html::asset('icon-connect.png')) . '" width="52" height="52" alt="" style="display:block;border:0;border-radius:26px;" /></td>'
324 + . '<td style="padding-left:16px;' . $font . '">'
325 + . '<div style="font-size:20px;font-weight:800;color:' . Email_Report_Html::INK . ';letter-spacing:-0.01em;line-height:1.3;">'
326 + . esc_html__('Connect Google Search Console to get your first report', 'thinkrank') . '</div>'
327 + . '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">'
328 + . esc_html(sprintf(
329 + /* translators: %s: site title. */
330 + __('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'),
331 + $site_title
332 + )) . '</div>'
333 + . '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">'
334 + . '<strong style="color:' . Email_Report_Html::INK . ';">' . esc_html__('Scheduled reports are paused', 'thinkrank') . '</strong> '
335 + . esc_html__('until Search Console is connected. Nothing will be sent to your recipients in the meantime.', 'thinkrank') . '</div>'
336 + . '<div style="margin-top:18px;">'
337 + . '<a href="' . esc_url($connect) . '" style="display:inline-block;background:' . Email_Report_Html::PRIMARY . ';color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;padding:12px 22px;border-radius:10px;">'
338 + . esc_html__('Connect Search Console', 'thinkrank') . '</a>'
339 + . '&nbsp;&nbsp;<a href="' . esc_url($settings) . '" style="font-size:14px;font-weight:600;color:' . Email_Report_Html::PRIMARY . ';text-decoration:none;">'
340 + . esc_html__('Report settings', 'thinkrank') . ' ›</a></div>'
341 + . '</td></tr></table>';
342 +
343 + $items = [
344 + __('Clicks, impressions, CTR and average position, with the change vs the previous period', 'thinkrank'),
345 + __('Top growing pages and queries, and the ones losing ground', 'thinkrank'),
346 + __('Where your keywords rank: top 3, page one, page two and beyond', 'thinkrank'),
347 + __('Site traffic from Google Analytics 4 and visits from AI assistants, when those are connected', 'thinkrank'),
348 + ];
349 + $list = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;' . $font . 'font-size:14px;color:' . Email_Report_Html::INK . ';line-height:1.6;">';
350 + foreach ($items as $item) {
351 + $list .= '<tr><td width="22" valign="top" style="padding:6px 0;color:' . Email_Report_Html::UP . ';font-weight:700;">✓</td>'
352 + . '<td style="padding:6px 0;">' . esc_html($item) . '</td></tr>';
151 353 }
152 - // No bundled default logo asset yet — return empty so the layout
153 - // falls back to a text-rendered site title in the header. Pro
154 - // (or a follow-up) can ship a real PNG and wire it via this filter.
354 + $list .= '</table>';
355 +
356 + return self::card($intro, 'tr-email-section tr-email-connect', '28px')
357 + . self::card(Email_Report_Html::heading(__('What you’ll get once connected', 'thinkrank')) . $list, 'tr-email-section tr-email-connect-list');
358 + }
359 +
360 + private function default_logo(): string {
155 361 /**
156 362 * Filter the default email logo URL.
157 363 *
364 + * The bundled ThinkRank lockup by default (#742). Pro's branding
365 + * logo replaces it through the payload filter, not here.
366 + *
158 367 * @since 1.9.0
159 368 *
160 - * @param string $default Default logo URL — empty by default.
369 + * @param string $default Default logo URL.
161 370 */
162 - return (string) apply_filters('thinkrank_email_report_default_logo', '');
371 + return (string) apply_filters('thinkrank_email_report_default_logo', Email_Report_Html::asset('thinkrank-logo.png'));
163 372 }
164 373
165 - private function resolve_cta_url(array $config, array $caps): string {
166 - if (empty($config['link_to_full_report'])) {
167 - return '';
374 + private function dashboard_url(): string {
375 + return Email_Report_Html::admin_link('analytics', 'dashboard');
376 + }
377 +
378 + /**
379 + * "Monthly SEO report" / "Weekly SEO report" / "SEO report every N days"
380 + * for the header's right-hand label.
381 + */
382 + private function frequency_label(int $days): string {
383 + if ($days === 1) {
384 + return __('Daily SEO report', 'thinkrank');
168 385 }
169 - // The dashboard analytics view URL — admin-side. The recipient must
170 - // be logged in to see it, but the link still gives them a clear path.
171 - return (string) admin_url('admin.php?page=thinkrank-essential-seo#analytics');
386 + if ($days === 7) {
387 + return __('Weekly SEO report', 'thinkrank');
388 + }
389 + if ($days >= 28 && $days <= 31) {
390 + return __('Monthly SEO report', 'thinkrank');
391 + }
392 + return sprintf(
393 + /* translators: %d: number of days between reports. */
394 + _n('SEO report every %d day', 'SEO report every %d days', $days, 'thinkrank'),
395 + $days
396 + );
172 397 }
173 398
174 399 /**
175 - * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
400 + * Substitute %site_title%, %site_url%, %date% and %period%
176 401 * in free-text fields like the intro and footer. Previously only the subject
177 402 * line ran token substitution, so these tokens rendered literally in the body.
178 403 *
179 404 * @param string $text Raw text, possibly containing %tokens%.
@@ -207,14 +432,40 @@
207 432
208 433 return strtr($text, $tokens);
209 434 }
210 435
211 - private function default_footer(): string {
212 - return sprintf(
213 - /* translators: %s: site title */
214 - esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'),
215 - esc_html((string) get_bloginfo('name'))
436 + /**
437 + * "Sent by ThinkRank for example.com · every 30 days to [email protected], [email protected]"
438 + */
439 + private function default_footer(array $config): string {
440 + $days = max(1, (int) ($config['frequency_days'] ?? 30));
441 + $recipients = array_values(array_filter((array) ($config['recipients'] ?? []), 'is_string'));
442 + $host = (string) wp_parse_url((string) home_url(), PHP_URL_HOST);
443 +
444 + $line = sprintf(
445 + /* translators: %s: site host name. */
446 + __('Sent by ThinkRank for %s', 'thinkrank'),
447 + $host !== '' ? $host : (string) get_bloginfo('name')
216 448 );
449 + $cadence = $days === 1
450 + ? __('every day', 'thinkrank')
451 + : sprintf(
452 + /* translators: %d: number of days between reports. */
453 + _n('every %d day', 'every %d days', $days, 'thinkrank'),
454 + $days
455 + );
456 + if ($recipients !== []) {
457 + $line .= ' · ' . sprintf(
458 + /* translators: 1: cadence ("every 30 days"), 2: recipient list. */
459 + __('%1$s to %2$s', 'thinkrank'),
460 + $cadence,
461 + implode(', ', array_slice($recipients, 0, 3)) . (count($recipients) > 3 ? '…' : '')
462 + );
463 + } else {
464 + $line .= ' · ' . $cadence;
465 + }
466 +
467 + return esc_html($line);
217 468 }
218 469
219 470 private function locate_layout(): string {
220 471 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
@@ -227,11 +478,11 @@
227 478 */
228 479 private function emergency_fallback_html(array $payload): string {
229 480 $title = esc_html($payload['site_title'] ?? '');
230 481 $sections = $payload['sections_html'] ?? '';
231 - return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">'
232 - . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">'
233 - . '<h1 style="margin:0 0 16px 0;font-size:22px;color:#111827;">' . $title . '</h1>'
482 + return '<!doctype html><html><body style="' . Email_Report_Html::FONT . 'background:' . Email_Report_Html::CANVAS . ';padding:24px;">'
483 + . '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;">'
484 + . '<tr><td style="padding:0 0 16px 0;"><h1 style="margin:0;font-size:22px;color:' . Email_Report_Html::INK . ';">' . $title . '</h1></td></tr>'
234 485 . $sections
235 - . '</div></body></html>';
486 + . '</table></body></html>';
236 487 }
237 488 }