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

238 lines 8.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 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 * Walk the resolved sections, render each, return concatenated HTML.
111 */
112 private function render_sections(array $config, array $context): string {
113 $sections = $this->registry->resolve_for($config);
114 $html = '';
115
116 foreach ($sections as $section) {
117 $section_html = $this->render_one_section($section, $context);
118 $html .= $this->wrap_section($section, $section_html);
119 }
120
121 return $html;
122 }
123
124 private function render_one_section($section, array $context): string {
125 try {
126 $payload = $section->collect($context);
127 if (empty($payload)) {
128 return $section->fallback_html();
129 }
130 $rendered = $section->render($payload);
131 return $rendered !== '' ? $rendered : $section->fallback_html();
132 } catch (Throwable $e) {
133 // Don't let one section break the report.
134 return $section->fallback_html();
135 }
136 }
137
138 private function wrap_section($section, string $body_html): string {
139 $heading = esc_html($section->label());
140 return '<section class="tr-email-section" style="margin:0 0 24px 0;">'
141 . '<h2 style="font:600 18px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;margin:0 0 12px 0;color:#111827;">'
142 . $heading
143 . '</h2>'
144 . $body_html
145 . '</section>';
146 }
147
148 private function resolve_logo(array $config, array $caps): string {
149 if (!empty($caps['custom_logo']) && !empty($config['logo_url'])) {
150 return (string) $config['logo_url'];
151 }
152 // No bundled default logo asset yet — return empty so the layout
153 // falls back to a text-rendered site title in the header. Pro
154 // (or a follow-up) can ship a real PNG and wire it via this filter.
155 /**
156 * Filter the default email logo URL.
157 *
158 * @since 1.9.0
159 *
160 * @param string $default Default logo URL — empty by default.
161 */
162 return (string) apply_filters('thinkrank_email_report_default_logo', '');
163 }
164
165 private function resolve_cta_url(array $config, array $caps): string {
166 if (empty($config['link_to_full_report'])) {
167 return '';
168 }
169 // The dashboard analytics view URL — admin-side. The recipient must
170 // be logged in to see it, but the link still gives them a clear path.
171 return (string) admin_url('admin.php?page=thinkrank-essential-seo#analytics');
172 }
173
174 /**
175 * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens)
176 * in free-text fields like the intro and footer. Previously only the subject
177 * line ran token substitution, so these tokens rendered literally in the body.
178 *
179 * @param string $text Raw text, possibly containing %tokens%.
180 * @param array $shared_context Render context (supplies the period label).
181 * @return string Text with tokens replaced.
182 */
183 private function apply_text_tokens(string $text, array $shared_context): string {
184 if ($text === '' || strpos($text, '%') === false) {
185 return $text;
186 }
187
188 $tokens = [
189 '%site_title%' => (string) get_bloginfo('name'),
190 '%site_url%' => (string) home_url(),
191 '%date%' => wp_date(get_option('date_format', 'Y-m-d')),
192 '%period%' => (string) ($shared_context['period_label'] ?? ''),
193 ];
194
195 /**
196 * Filter the token map used for email report free-text fields.
197 *
198 * Mirrors the subject-line token filter so Pro can register extra
199 * tokens (e.g. %client_name%) that resolve everywhere.
200 *
201 * @since 1.16.0
202 *
203 * @param array $tokens Token => replacement.
204 * @param array $shared_context Render context.
205 */
206 $tokens = (array) apply_filters('thinkrank_email_report_tokens', $tokens, $shared_context);
207
208 return strtr($text, $tokens);
209 }
210
211 private function default_footer(): string {
212 return sprintf(
213 /* translators: %s: site title */
214 esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'),
215 esc_html((string) get_bloginfo('name'))
216 );
217 }
218
219 private function locate_layout(): string {
220 return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php';
221 }
222
223 /**
224 * If the layout template is missing for some reason, render a minimal
225 * but still useful HTML email. Better than a blank message — the user
226 * still gets the data they care about.
227 */
228 private function emergency_fallback_html(array $payload): string {
229 $title = esc_html($payload['site_title'] ?? '');
230 $sections = $payload['sections_html'] ?? '';
231 return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">'
232 . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">'
233 . '<h1 style="margin:0 0 16px 0;font-size:22px;color:#111827;">' . $title . '</h1>'
234 . $sections
235 . '</div></body></html>';
236 }
237 }
238