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

249 lines 9.2 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 public function __construct(Email_Report_Section_Registry $registry) {
38 $this->registry = $registry;
39 }
40
41 /**
42 * Render the full HTML for a report.
43 *
44 * @param array $config Per-site config.
45 * @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".
50 * }
51 */
52 public function render(array $config, array $context): string {
53 $caps = Plan_Config::email_report();
54
55 $shared_context = array_merge([
56 'site_url' => (string) home_url(),
57 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
58 'period_start' => '',
59 'period_end' => '',
60 'period_label' => '',
61 'is_test' => false,
62 'shared' => [],
63 ], $context);
64
65 $sections_html = $this->render_sections($config, $shared_context);
66
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());
69
70 $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'),
83 ];
84
85 /**
86 * Filter the assembled payload before the layout template runs.
87 *
88 * Pro can rewrite logo/header/footer here without touching the renderer.
89 *
90 * @since 1.9.0
91 *
92 * @param array $payload
93 * @param array $config
94 * @param array $shared_context
95 */
96 $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context);
97
98 $layout = $this->locate_layout();
99 if (!is_readable($layout)) {
100 return $this->emergency_fallback_html($payload);
101 }
102
103 ob_start();
104 // The layout file expects $payload in scope.
105 include $layout;
106 return (string) ob_get_clean();
107 }
108
109 /**
110 * Whether this config resolves to at least one section.
111 *
112 * A report with everything switched off still produced a valid email —
113 * header, footer, and nothing in between — and reported it as a
114 * successful send. Callers use this to skip the send instead.
115 */
116 public function has_renderable_sections(array $config): bool {
117 return $this->registry->resolve_for($config) !== [];
118 }
119
120 /**
121 * Walk the resolved sections, render each, return concatenated HTML.
122 */
123 private function render_sections(array $config, array $context): string {
124 $sections = $this->registry->resolve_for($config);
125 $html = '';
126
127 foreach ($sections as $section) {
128 $section_html = $this->render_one_section($section, $context);
129 $html .= $this->wrap_section($section, $section_html);
130 }
131
132 return $html;
133 }
134
135 private function render_one_section($section, array $context): string {
136 try {
137 $payload = $section->collect($context);
138 if (empty($payload)) {
139 return $section->fallback_html();
140 }
141 $rendered = $section->render($payload);
142 return $rendered !== '' ? $rendered : $section->fallback_html();
143 } catch (Throwable $e) {
144 // Don't let one section break the report.
145 return $section->fallback_html();
146 }
147 }
148
149 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>';
157 }
158
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'];
162 }
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.
166 /**
167 * Filter the default email logo URL.
168 *
169 * @since 1.9.0
170 *
171 * @param string $default Default logo URL — empty by default.
172 */
173 return (string) apply_filters('thinkrank_email_report_default_logo', '');
174 }
175
176 private function resolve_cta_url(array $config, array $caps): string {
177 if (empty($config['link_to_full_report'])) {
178 return '';
179 }
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');
183 }
184
185 /**
186 * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
187 * in free-text fields like the intro and footer. Previously only the subject
188 * line ran token substitution, so these tokens rendered literally in the body.
189 *
190 * @param string $text Raw text, possibly containing %tokens%.
191 * @param array $shared_context Render context (supplies the period label).
192 * @return string Text with tokens replaced.
193 */
194 private function apply_text_tokens(string $text, array $shared_context): string {
195 if ($text === '' || strpos($text, '%') === false) {
196 return $text;
197 }
198
199 $tokens = [
200 '%site_title%' => (string) get_bloginfo('name'),
201 '%site_url%' => (string) home_url(),
202 '%date%' => wp_date(get_option('date_format', 'Y-m-d')),
203 '%period%' => (string) ($shared_context['period_label'] ?? ''),
204 ];
205
206 /**
207 * Filter the token map used for email report free-text fields.
208 *
209 * Mirrors the subject-line token filter so Pro can register extra
210 * tokens (e.g. %client_name%) that resolve everywhere.
211 *
212 * @since 1.16.0
213 *
214 * @param array $tokens Token => replacement.
215 * @param array $shared_context Render context.
216 */
217 $tokens = (array) apply_filters('thinkrank_email_report_tokens', $tokens, $shared_context);
218
219 return strtr($text, $tokens);
220 }
221
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'))
227 );
228 }
229
230 private function locate_layout(): string {
231 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
232 }
233
234 /**
235 * If the layout template is missing for some reason, render a minimal
236 * but still useful HTML email. Better than a blank message — the user
237 * still gets the data they care about.
238 */
239 private function emergency_fallback_html(array $payload): string {
240 $title = esc_html($payload['site_title'] ?? '');
241 $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>'
245 . $sections
246 . '</div></body></html>';
247 }
248 }
249