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 +300 -60 2.0.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 }
@@ -111,8 +142,14 @@
111 142 *
112 143 * A report with everything switched off still produced a valid email —
113 144 * header, footer, and nothing in between — and reported it as a
114 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.
115 152 */
116 153 public function has_renderable_sections(array $config): bool {
117 154 return $this->registry->resolve_for($config) !== [];
118 155 }
@@ -125,8 +162,11 @@
125 162 $html = '';
126 163
127 164 foreach ($sections as $section) {
128 165 $section_html = $this->render_one_section($section, $context);
166 + if ($section_html === '') {
167 + continue;
168 + }
129 169 $html .= $this->wrap_section($section, $section_html);
130 170 }
131 171
132 172 return $html;
@@ -135,56 +175,230 @@
135 175 private function render_one_section($section, array $context): string {
136 176 try {
137 177 $payload = $section->collect($context);
138 178 if (empty($payload)) {
139 - return $section->fallback_html();
179 + return $this->fallback_for($section);
140 180 }
141 181 $rendered = $section->render($payload);
142 - 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;
143 198 } catch (Throwable $e) {
144 199 // Don't let one section break the report.
145 - return $section->fallback_html();
200 + return $this->fallback_for($section);
146 201 }
147 202 }
148 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 + */
149 291 private function wrap_section($section, string $body_html): string {
150 - $heading = esc_html($section->label());
151 - return '<section class="tr-email-section" style="margin:0 0 24px 0;">'
152 - . '<h2 style="font:600 18px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;margin:0 0 12px 0;color:#111827;">'
153 - . $heading
154 - . '</h2>'
155 - . $body_html
156 - . '</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()));
157 299 }
158 300
159 - private function resolve_logo(array $config, array $caps): string {
160 - if (!empty($caps['custom_logo']) && !empty($config['logo_url'])) {
161 - 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>';
162 353 }
163 - // No bundled default logo asset yet — return empty so the layout
164 - // falls back to a text-rendered site title in the header. Pro
165 - // (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 {
166 361 /**
167 362 * Filter the default email logo URL.
168 363 *
364 + * The bundled ThinkRank lockup by default (#742). Pro's branding
365 + * logo replaces it through the payload filter, not here.
366 + *
169 367 * @since 1.9.0
170 368 *
171 - * @param string $default Default logo URL — empty by default.
369 + * @param string $default Default logo URL.
172 370 */
173 - 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'));
174 372 }
175 373
176 - private function resolve_cta_url(array $config, array $caps): string {
177 - if (empty($config['link_to_full_report'])) {
178 - 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');
179 385 }
180 - // The dashboard analytics view URL — admin-side. The recipient must
181 - // be logged in to see it, but the link still gives them a clear path.
182 - 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 + );
183 397 }
184 398
185 399 /**
186 - * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
400 + * Substitute %site_title%, %site_url%, %date% and %period%
187 401 * in free-text fields like the intro and footer. Previously only the subject
188 402 * line ran token substitution, so these tokens rendered literally in the body.
189 403 *
190 404 * @param string $text Raw text, possibly containing %tokens%.
@@ -218,14 +432,40 @@
218 432
219 433 return strtr($text, $tokens);
220 434 }
221 435
222 - private function default_footer(): string {
223 - return sprintf(
224 - /* translators: %s: site title */
225 - esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'),
226 - 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')
227 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);
228 468 }
229 469
230 470 private function locate_layout(): string {
231 471 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
@@ -238,11 +478,11 @@
238 478 */
239 479 private function emergency_fallback_html(array $payload): string {
240 480 $title = esc_html($payload['site_title'] ?? '');
241 481 $sections = $payload['sections_html'] ?? '';
242 - return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">'
243 - . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">'
244 - . '<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>'
245 485 . $sections
246 - . '</div></body></html>';
486 + . '</table></body></html>';
247 487 }
248 488 }