PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.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 1.1.0 All 49 releases
← All changes | includes/seo/class-email-report-renderer.php +209 -70 2.4.02.8.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;
@@ -36,12 +39,12 @@
36 39
37 40 /**
38 41 * How many sections produced real data during the last render().
39 42 *
40 - * A section that returns an empty payload (or renders to nothing) drops
41 - * to its fallback_html() notice. That outcome is only known *inside*
42 - * render_one_section(), so it is counted here for the caller to read
43 - * back after rendering — see sections_with_data().
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().
44 47 */
45 48 private int $sections_with_data = 0;
46 49
47 50 public function __construct(Email_Report_Section_Registry $registry) {
@@ -52,12 +55,15 @@
52 55 * Render the full HTML for a report.
53 56 *
54 57 * @param array $config Per-site config.
55 58 * @param array $context {
56 - * @type string $period_start ISO datetime.
57 - * @type string $period_end ISO datetime.
58 - * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026".
59 - * @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.
60 66 * }
61 67 */
62 68 public function render(array $config, array $context): string {
63 69 // Reset before walking the sections so sections_with_data() always
@@ -63,10 +69,8 @@
63 69 // Reset before walking the sections so sections_with_data() always
64 70 // describes this render and never a previous one.
65 71 $this->sections_with_data = 0;
66 72
67 - $caps = Plan_Config::email_report();
68 -
69 73 $shared_context = array_merge([
70 74 'site_url' => (string) home_url(),
71 75 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
72 76 'period_start' => '',
@@ -72,35 +76,43 @@
72 76 'period_start' => '',
73 77 'period_end' => '',
74 78 'period_label' => '',
75 79 'is_test' => false,
80 + 'not_connected' => false,
81 + 'readiness' => [],
76 82 'shared' => [],
77 83 ], $context);
78 84
79 - $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);
80 88
81 - $intro_text = empty($caps['intro_text']) ? '' : (string) ($config['intro_text'] ?? '');
82 - $footer_text = empty($caps['footer_text']) ? $this->default_footer() : (string) ($config['footer_text'] ?? $this->default_footer());
89 + $frequency_days = (int) $shared_context['frequency_days'];
83 90
84 91 $payload = [
85 - 'config' => $config,
86 - 'caps' => $caps,
87 - 'context' => $shared_context,
88 - 'sections_html' => $sections_html,
89 - 'header_logo' => $this->resolve_logo($config, $caps),
90 - 'header_bg' => empty($caps['header_background']) ? '' : (string) ($config['header_background'] ?? ''),
91 - 'logo_link' => empty($caps['logo_link']) ? '' : (string) ($config['logo_link'] ?? ''),
92 - 'intro_text' => $this->apply_text_tokens($intro_text, $shared_context),
93 - 'footer_text' => $this->apply_text_tokens($footer_text, $shared_context),
94 - 'additional_css' => empty($caps['additional_css']) ? '' : (string) ($config['additional_css'] ?? ''),
95 - 'cta_url' => $this->resolve_cta_url($config, $caps),
96 - '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),
97 107 ];
98 108
99 109 /**
100 110 * Filter the assembled payload before the layout template runs.
101 111 *
102 - * 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.
103 115 *
104 116 * @since 1.9.0
105 117 *
106 118 * @param array $payload
@@ -108,8 +120,13 @@
108 120 * @param array $shared_context
109 121 */
110 122 $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context);
111 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 +
112 129 $layout = $this->locate_layout();
113 130 if (!is_readable($layout)) {
114 131 return $this->emergency_fallback_html($payload);
115 132 }
@@ -145,8 +162,11 @@
145 162 $html = '';
146 163
147 164 foreach ($sections as $section) {
148 165 $section_html = $this->render_one_section($section, $context);
166 + if ($section_html === '') {
167 + continue;
168 + }
149 169 $html .= $this->wrap_section($section, $section_html);
150 170 }
151 171
152 172 return $html;
@@ -155,13 +175,13 @@
155 175 private function render_one_section($section, array $context): string {
156 176 try {
157 177 $payload = $section->collect($context);
158 178 if (empty($payload)) {
159 - return $section->fallback_html();
179 + return $this->fallback_for($section);
160 180 }
161 181 $rendered = $section->render($payload);
162 182 if ($rendered === '') {
163 - return $section->fallback_html();
183 + return $this->fallback_for($section);
164 184 }
165 185
166 186 // A non-empty payload is not the same as data. Key Metrics
167 187 // collects whenever the provider reports itself "available",
@@ -167,20 +187,35 @@
167 187 // collects whenever the provider reports itself "available",
168 188 // which it does even with no Search Console connection — and
169 189 // then renders a row of zeroes. That is a placeholder, not a
170 190 // report, so it must not make an empty send look non-empty.
171 - if ($this->payload_has_data($section, $payload)) {
172 - $this->sections_with_data++;
191 + if (!$this->payload_has_data($section, $payload)) {
192 + return $this->fallback_for($section);
173 193 }
174 194
195 + $this->sections_with_data++;
196 +
175 197 return $rendered;
176 198 } catch (Throwable $e) {
177 199 // Don't let one section break the report.
178 - return $section->fallback_html();
200 + return $this->fallback_for($section);
179 201 }
180 202 }
181 203
182 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 + /**
183 218 * Whether a section's payload carries anything worth reporting.
184 219 *
185 220 * A section may override the default judgement by implementing
186 221 * `has_data(array $payload): bool` — the interface does not require it,
@@ -238,54 +273,132 @@
238 273
239 274 /**
240 275 * How many sections produced real data in the most recent render().
241 276 *
242 - * Zero means every section fell back to its "no data" notice, so the
243 - * report is a header, a footer and a column of placeholders. Callers
244 - * use this to decide whether that is worth sending.
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.
245 280 */
246 281 public function sections_with_data(): int {
247 282 return $this->sections_with_data;
248 283 }
249 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 + */
250 291 private function wrap_section($section, string $body_html): string {
251 - $heading = esc_html($section->label());
252 - return '<section class="tr-email-section" style="margin:0 0 24px 0;">'
253 - . '<h2 style="font:600 18px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;margin:0 0 12px 0;color:#111827;">'
254 - . $heading
255 - . '</h2>'
256 - . $body_html
257 - . '</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()));
258 299 }
259 300
260 - private function resolve_logo(array $config, array $caps): string {
261 - if (!empty($caps['custom_logo']) && !empty($config['logo_url'])) {
262 - 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>';
263 353 }
264 - // No bundled default logo asset yet — return empty so the layout
265 - // falls back to a text-rendered site title in the header. Pro
266 - // (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 {
267 361 /**
268 362 * Filter the default email logo URL.
269 363 *
364 + * The bundled ThinkRank lockup by default (#742). Pro's branding
365 + * logo replaces it through the payload filter, not here.
366 + *
270 367 * @since 1.9.0
271 368 *
272 - * @param string $default Default logo URL — empty by default.
369 + * @param string $default Default logo URL.
273 370 */
274 - 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'));
275 372 }
276 373
277 - private function resolve_cta_url(array $config, array $caps): string {
278 - if (empty($config['link_to_full_report'])) {
279 - 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');
280 385 }
281 - // The dashboard analytics view URL — admin-side. The recipient must
282 - // be logged in to see it, but the link still gives them a clear path.
283 - 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 + );
284 397 }
285 398
286 399 /**
287 - * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
400 + * Substitute %site_title%, %site_url%, %date% and %period%
288 401 * in free-text fields like the intro and footer. Previously only the subject
289 402 * line ran token substitution, so these tokens rendered literally in the body.
290 403 *
291 404 * @param string $text Raw text, possibly containing %tokens%.
@@ -319,14 +432,40 @@
319 432
320 433 return strtr($text, $tokens);
321 434 }
322 435
323 - private function default_footer(): string {
324 - return sprintf(
325 - /* translators: %s: site title */
326 - esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'),
327 - esc_html((string) get_bloginfo('name'))
436 + /**
437 + * "Sent by ThinkRank for example.com · every 30 days to a@x.com, b@x.com"
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')
328 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);
329 468 }
330 469
331 470 private function locate_layout(): string {
332 471 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
@@ -339,11 +478,11 @@
339 478 */
340 479 private function emergency_fallback_html(array $payload): string {
341 480 $title = esc_html($payload['site_title'] ?? '');
342 481 $sections = $payload['sections_html'] ?? '';
343 - return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">'
344 - . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">'
345 - . '<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>'
346 485 . $sections
347 - . '</div></body></html>';
486 + . '</table></body></html>';
348 487 }
349 488 }