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 +201 -55 2.7.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,8 +20,9 @@
17 20 declare(strict_types=1);
18 21
19 22 namespace ThinkRank\SEO;
20 23
24 +use ThinkRank\SEO\Email_Report_Sections\Email_Report_Html;
21 25 use Throwable;
22 26
23 27 if (!defined('ABSPATH')) {
24 28 exit;
@@ -35,12 +39,12 @@
35 39
36 40 /**
37 41 * How many sections produced real data during the last render().
38 42 *
39 - * A section that returns an empty payload (or renders to nothing) drops
40 - * to its fallback_html() notice. That outcome is only known *inside*
41 - * render_one_section(), so it is counted here for the caller to read
42 - * 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().
43 47 */
44 48 private int $sections_with_data = 0;
45 49
46 50 public function __construct(Email_Report_Section_Registry $registry) {
@@ -51,12 +55,15 @@
51 55 * Render the full HTML for a report.
52 56 *
53 57 * @param array $config Per-site config.
54 58 * @param array $context {
55 - * @type string $period_start ISO datetime.
56 - * @type string $period_end ISO datetime.
57 - * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026".
58 - * @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.
59 66 * }
60 67 */
61 68 public function render(array $config, array $context): string {
62 69 // Reset before walking the sections so sections_with_data() always
@@ -69,25 +76,35 @@
69 76 'period_start' => '',
70 77 'period_end' => '',
71 78 'period_label' => '',
72 79 'is_test' => false,
80 + 'not_connected' => false,
81 + 'readiness' => [],
73 82 'shared' => [],
74 83 ], $context);
75 84
76 - $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);
77 88
89 + $frequency_days = (int) $shared_context['frequency_days'];
90 +
78 91 $payload = [
79 - 'config' => $config,
80 - 'context' => $shared_context,
81 - 'sections_html' => $sections_html,
82 - 'header_logo' => $this->default_logo(),
83 - 'header_bg' => '',
84 - 'logo_link' => '',
85 - 'intro_text' => '',
86 - 'footer_text' => $this->default_footer(),
87 - 'additional_css' => '',
88 - 'cta_url' => $this->dashboard_url(),
89 - '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),
90 107 ];
91 108
92 109 /**
93 110 * Filter the assembled payload before the layout template runs.
@@ -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,46 +273,131 @@
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
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>';
353 + }
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 +
260 360 private function default_logo(): string {
261 - // No bundled default logo asset — empty makes the layout fall back to
262 - // a text-rendered site title in the header.
263 361 /**
264 362 * Filter the default email logo URL.
265 363 *
364 + * The bundled ThinkRank lockup by default (#742). Pro's branding
365 + * logo replaces it through the payload filter, not here.
366 + *
266 367 * @since 1.9.0
267 368 *
268 - * @param string $default Default logo URL — empty by default.
369 + * @param string $default Default logo URL.
269 370 */
270 - 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'));
271 372 }
272 373
273 374 private function dashboard_url(): string {
274 - // The dashboard analytics view URL — admin-side. The recipient must
275 - // be logged in to see it, but the link still gives them a clear path.
276 - return (string) admin_url('admin.php?page=thinkrank-essential-seo#analytics');
375 + return Email_Report_Html::admin_link('analytics', 'dashboard');
277 376 }
278 377
279 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');
385 + }
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 + );
397 + }
398 +
399 + /**
280 400 * Substitute %site_title%, %site_url%, %date% and %period%
281 401 * in free-text fields like the intro and footer. Previously only the subject
282 402 * line ran token substitution, so these tokens rendered literally in the body.
283 403 *
@@ -312,14 +432,40 @@
312 432
313 433 return strtr($text, $tokens);
314 434 }
315 435
316 - private function default_footer(): string {
317 - return sprintf(
318 - /* translators: %s: site title */
319 - esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'),
320 - 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')
321 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);
322 468 }
323 469
324 470 private function locate_layout(): string {
325 471 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
@@ -332,11 +478,11 @@
332 478 */
333 479 private function emergency_fallback_html(array $payload): string {
334 480 $title = esc_html($payload['site_title'] ?? '');
335 481 $sections = $payload['sections_html'] ?? '';
336 - return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">'
337 - . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">'
338 - . '<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>'
339 485 . $sections
340 - . '</div></body></html>';
486 + . '</table></body></html>';
341 487 }
342 488 }