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
thinkrank / includes / seo / class-email-report-renderer.php

class-email-report-renderer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/seo/class-email-report-renderer.php

489 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Renderer
4 *
5 * Walks the registered sections, calls collect() then render() on each,
6 * wraps each result in a card and assembles the final HTML using
7 * templates/email-report/email.html.php.
8 *
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).
14 *
15 * @package ThinkRank
16 * @subpackage SEO
17 * @since 1.9.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\SEO;
23
24 use ThinkRank\SEO\Email_Report_Sections\Email_Report_Html;
25 use Throwable;
26
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Email_Report_Renderer
33 *
34 * @since 1.9.0
35 */
36 final class Email_Report_Renderer {
37
38 private Email_Report_Section_Registry $registry;
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
50 public function __construct(Email_Report_Section_Registry $registry) {
51 $this->registry = $registry;
52 }
53
54 /**
55 * Render the full HTML for a report.
56 *
57 * @param array $config Per-site config.
58 * @param array $context {
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.
66 * }
67 */
68 public function render(array $config, array $context): string {
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;
72
73 $shared_context = array_merge([
74 'site_url' => (string) home_url(),
75 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
76 'period_start' => '',
77 'period_end' => '',
78 'period_label' => '',
79 'is_test' => false,
80 'not_connected' => false,
81 'readiness' => [],
82 'shared' => [],
83 ], $context);
84
85 $sections_html = !empty($shared_context['not_connected'])
86 ? $this->render_not_connected($shared_context)
87 : $this->render_sections($config, $shared_context);
88
89 $frequency_days = (int) $shared_context['frequency_days'];
90
91 $payload = [
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),
107 ];
108
109 /**
110 * Filter the assembled payload before the layout template runs.
111 *
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.
115 *
116 * @since 1.9.0
117 *
118 * @param array $payload
119 * @param array $config
120 * @param array $shared_context
121 */
122 $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context);
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
129 $layout = $this->locate_layout();
130 if (!is_readable($layout)) {
131 return $this->emergency_fallback_html($payload);
132 }
133
134 ob_start();
135 // The layout file expects $payload in scope.
136 include $layout;
137 return (string) ob_get_clean();
138 }
139
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 /**
158 * Walk the resolved sections, render each, return concatenated HTML.
159 */
160 private function render_sections(array $config, array $context): string {
161 $sections = $this->registry->resolve_for($config);
162 $html = '';
163
164 foreach ($sections as $section) {
165 $section_html = $this->render_one_section($section, $context);
166 if ($section_html === '') {
167 continue;
168 }
169 $html .= $this->wrap_section($section, $section_html);
170 }
171
172 return $html;
173 }
174
175 private function render_one_section($section, array $context): string {
176 try {
177 $payload = $section->collect($context);
178 if (empty($payload)) {
179 return $this->fallback_for($section);
180 }
181 $rendered = $section->render($payload);
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;
198 } catch (Throwable $e) {
199 // Don't let one section break the report.
200 return $this->fallback_for($section);
201 }
202 }
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 */
291 private function wrap_section($section, string $body_html): string {
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()));
299 }
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
360 private function default_logo(): string {
361 /**
362 * Filter the default email logo URL.
363 *
364 * The bundled ThinkRank lockup by default (#742). Pro's branding
365 * logo replaces it through the payload filter, not here.
366 *
367 * @since 1.9.0
368 *
369 * @param string $default Default logo URL.
370 */
371 return (string) apply_filters('thinkrank_email_report_default_logo', Email_Report_Html::asset('thinkrank-logo.png'));
372 }
373
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');
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 /**
400 * Substitute %site_title%, %site_url%, %date% and %period%
401 * in free-text fields like the intro and footer. Previously only the subject
402 * line ran token substitution, so these tokens rendered literally in the body.
403 *
404 * @param string $text Raw text, possibly containing %tokens%.
405 * @param array $shared_context Render context (supplies the period label).
406 * @return string Text with tokens replaced.
407 */
408 private function apply_text_tokens(string $text, array $shared_context): string {
409 if ($text === '' || strpos($text, '%') === false) {
410 return $text;
411 }
412
413 $tokens = [
414 '%site_title%' => (string) get_bloginfo('name'),
415 '%site_url%' => (string) home_url(),
416 '%date%' => wp_date(get_option('date_format', 'Y-m-d')),
417 '%period%' => (string) ($shared_context['period_label'] ?? ''),
418 ];
419
420 /**
421 * Filter the token map used for email report free-text fields.
422 *
423 * Mirrors the subject-line token filter so Pro can register extra
424 * tokens (e.g. %client_name%) that resolve everywhere.
425 *
426 * @since 1.16.0
427 *
428 * @param array $tokens Token => replacement.
429 * @param array $shared_context Render context.
430 */
431 $tokens = (array) apply_filters('thinkrank_email_report_tokens', $tokens, $shared_context);
432
433 return strtr($text, $tokens);
434 }
435
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')
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);
468 }
469
470 private function locate_layout(): string {
471 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
472 }
473
474 /**
475 * If the layout template is missing for some reason, render a minimal
476 * but still useful HTML email. Better than a blank message — the user
477 * still gets the data they care about.
478 */
479 private function emergency_fallback_html(array $payload): string {
480 $title = esc_html($payload['site_title'] ?? '');
481 $sections = $payload['sections_html'] ?? '';
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>'
485 . $sections
486 . '</table></body></html>';
487 }
488 }
489