PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.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 1.10.0 All 48 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.3.0, at includes/seo/class-email-report-renderer.php

350 lines 12.8 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 * and assembles the final HTML using templates/email-report/email.html.php.
7 *
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.
11 *
12 * @package ThinkRank
13 * @subpackage SEO
14 * @since 1.9.0
15 */
16
17 declare(strict_types=1);
18
19 namespace ThinkRank\SEO;
20
21 use ThinkRank\Core\Plan_Config;
22 use Throwable;
23
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Email_Report_Renderer
30 *
31 * @since 1.9.0
32 */
33 final class Email_Report_Renderer {
34
35 private Email_Report_Section_Registry $registry;
36
37 /**
38 * How many sections produced real data during the last render().
39 *
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().
44 */
45 private int $sections_with_data = 0;
46
47 public function __construct(Email_Report_Section_Registry $registry) {
48 $this->registry = $registry;
49 }
50
51 /**
52 * Render the full HTML for a report.
53 *
54 * @param array $config Per-site config.
55 * @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".
60 * }
61 */
62 public function render(array $config, array $context): string {
63 // Reset before walking the sections so sections_with_data() always
64 // describes this render and never a previous one.
65 $this->sections_with_data = 0;
66
67 $caps = Plan_Config::email_report();
68
69 $shared_context = array_merge([
70 'site_url' => (string) home_url(),
71 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
72 'period_start' => '',
73 'period_end' => '',
74 'period_label' => '',
75 'is_test' => false,
76 'shared' => [],
77 ], $context);
78
79 $sections_html = $this->render_sections($config, $shared_context);
80
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());
83
84 $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'),
97 ];
98
99 /**
100 * Filter the assembled payload before the layout template runs.
101 *
102 * Pro can rewrite logo/header/footer here without touching the renderer.
103 *
104 * @since 1.9.0
105 *
106 * @param array $payload
107 * @param array $config
108 * @param array $shared_context
109 */
110 $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context);
111
112 $layout = $this->locate_layout();
113 if (!is_readable($layout)) {
114 return $this->emergency_fallback_html($payload);
115 }
116
117 ob_start();
118 // The layout file expects $payload in scope.
119 include $layout;
120 return (string) ob_get_clean();
121 }
122
123 /**
124 * Whether this config resolves to at least one section.
125 *
126 * A report with everything switched off still produced a valid email —
127 * header, footer, and nothing in between — and reported it as a
128 * successful send. Callers use this to skip the send instead.
129 *
130 * This is a *configuration* check: it answers "is anything enabled?",
131 * not "did anything have data?". A section that is enabled but returns
132 * nothing still counts here, because emptiness is only discovered later,
133 * during render(). For the data question use sections_with_data() after
134 * rendering.
135 */
136 public function has_renderable_sections(array $config): bool {
137 return $this->registry->resolve_for($config) !== [];
138 }
139
140 /**
141 * Walk the resolved sections, render each, return concatenated HTML.
142 */
143 private function render_sections(array $config, array $context): string {
144 $sections = $this->registry->resolve_for($config);
145 $html = '';
146
147 foreach ($sections as $section) {
148 $section_html = $this->render_one_section($section, $context);
149 $html .= $this->wrap_section($section, $section_html);
150 }
151
152 return $html;
153 }
154
155 private function render_one_section($section, array $context): string {
156 try {
157 $payload = $section->collect($context);
158 if (empty($payload)) {
159 return $section->fallback_html();
160 }
161 $rendered = $section->render($payload);
162 if ($rendered === '') {
163 return $section->fallback_html();
164 }
165
166 // A non-empty payload is not the same as data. Key Metrics
167 // collects whenever the provider reports itself "available",
168 // which it does even with no Search Console connection — and
169 // then renders a row of zeroes. That is a placeholder, not a
170 // 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++;
173 }
174
175 return $rendered;
176 } catch (Throwable $e) {
177 // Don't let one section break the report.
178 return $section->fallback_html();
179 }
180 }
181
182 /**
183 * Whether a section's payload carries anything worth reporting.
184 *
185 * A section may override the default judgement by implementing
186 * `has_data(array $payload): bool` — the interface does not require it,
187 * so Pro sections with their own notion of emptiness can opt in without
188 * every existing section having to change.
189 *
190 * The default is a structural read: any non-zero number, any non-empty
191 * string, any true flag counts. An all-zero payload — six metrics at 0,
192 * no keyword rows — does not. Sections that already return an empty
193 * payload when they have nothing never reach this check.
194 *
195 * @param object $section
196 */
197 private function payload_has_data($section, array $payload): bool {
198 if (method_exists($section, 'has_data')) {
199 return (bool) $section->has_data($payload);
200 }
201
202 return $this->array_has_value($payload);
203 }
204
205 private function array_has_value(array $values): bool {
206 foreach ($values as $value) {
207 if (is_array($value)) {
208 if ($this->array_has_value($value)) {
209 return true;
210 }
211 continue;
212 }
213 if (is_bool($value)) {
214 if ($value) {
215 return true;
216 }
217 continue;
218 }
219 if (is_int($value) || is_float($value)) {
220 if (abs((float) $value) > 0.0) {
221 return true;
222 }
223 continue;
224 }
225 if (is_string($value)) {
226 if (trim($value) !== '') {
227 return true;
228 }
229 continue;
230 }
231 if ($value !== null) {
232 return true;
233 }
234 }
235
236 return false;
237 }
238
239 /**
240 * How many sections produced real data in the most recent render().
241 *
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.
245 */
246 public function sections_with_data(): int {
247 return $this->sections_with_data;
248 }
249
250 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>';
258 }
259
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'];
263 }
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.
267 /**
268 * Filter the default email logo URL.
269 *
270 * @since 1.9.0
271 *
272 * @param string $default Default logo URL — empty by default.
273 */
274 return (string) apply_filters('thinkrank_email_report_default_logo', '');
275 }
276
277 private function resolve_cta_url(array $config, array $caps): string {
278 if (empty($config['link_to_full_report'])) {
279 return '';
280 }
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');
284 }
285
286 /**
287 * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
288 * in free-text fields like the intro and footer. Previously only the subject
289 * line ran token substitution, so these tokens rendered literally in the body.
290 *
291 * @param string $text Raw text, possibly containing %tokens%.
292 * @param array $shared_context Render context (supplies the period label).
293 * @return string Text with tokens replaced.
294 */
295 private function apply_text_tokens(string $text, array $shared_context): string {
296 if ($text === '' || strpos($text, '%') === false) {
297 return $text;
298 }
299
300 $tokens = [
301 '%site_title%' => (string) get_bloginfo('name'),
302 '%site_url%' => (string) home_url(),
303 '%date%' => wp_date(get_option('date_format', 'Y-m-d')),
304 '%period%' => (string) ($shared_context['period_label'] ?? ''),
305 ];
306
307 /**
308 * Filter the token map used for email report free-text fields.
309 *
310 * Mirrors the subject-line token filter so Pro can register extra
311 * tokens (e.g. %client_name%) that resolve everywhere.
312 *
313 * @since 1.16.0
314 *
315 * @param array $tokens Token => replacement.
316 * @param array $shared_context Render context.
317 */
318 $tokens = (array) apply_filters('thinkrank_email_report_tokens', $tokens, $shared_context);
319
320 return strtr($text, $tokens);
321 }
322
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'))
328 );
329 }
330
331 private function locate_layout(): string {
332 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
333 }
334
335 /**
336 * If the layout template is missing for some reason, render a minimal
337 * but still useful HTML email. Better than a blank message — the user
338 * still gets the data they care about.
339 */
340 private function emergency_fallback_html(array $payload): string {
341 $title = esc_html($payload['site_title'] ?? '');
342 $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>'
346 . $sections
347 . '</div></body></html>';
348 }
349 }
350