| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 93.46% covered (success) | 93.46% | 100 / 107 | 72.73% covered (warning) | 72.73% | 8 / 11 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
BacklinksRenderer | 93.46% covered (success) | 93.46% | 100 / 107 | 72.73% covered (warning) | 72.73% | 8 / 11 | 46.59 | 0.00% covered (danger) | 0.00% | 0 / 1
render | 94.74% covered (success) | 94.74% | 18 / 19 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6.01 |
clearCache | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
trackTransient | 66.67% covered (warning) | 66.67% | 2 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.33 |
invalidateAll | 100.00% covered (success) | 100.00% | 8 / 8 | 100.00% covered (success) | 100.00% | 1 / 1 | 5 |
resetTracking | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
cacheKey | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
fetchApproved | 84.85% covered (warning) | 84.85% | 28 / 33 | 0.00% covered (danger) | 0.00% | 0 / 1 | 11.42 |
renderBacklinks | 100.00% covered (success) | 100.00% | 10 / 10 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
renderOne | 100.00% covered (success) | 100.00% | 18 / 18 | 100.00% covered (success) | 100.00% | 1 / 1 | 9 |
clampCount | 100.00% covered (success) | 100.00% | 5 / 5 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
normalizeMode | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |

1 | <?php
2 | /**
3 | * SwapAds Client — BacklinksRenderer (F236).
4 | *
5 | * Single source of truth for rendering approved (received) backlinks
6 | * on the operator's site via the shortcode OR the Gutenberg block.
7 | * Both surfaces (BacklinksShortcode + BacklinksBlock) call into this
8 | * renderer so output stays in lockstep.
9 | *
10 | * Display modes:
11 | * - list (default): one link per row, with host suffix in parens
12 | * - card: boxed layout, anchor + host as a separate line
13 | * - text: inline anchors separated by spaces (for footers)
14 | *
15 | * Server data:
16 | * - GET /v1/backlinks/approved (HMAC-signed)
17 | * - Returns { backlinks: [{url, anchor_text, rel_attribute}, ...], count }
18 | * - Filters: audience (string), category (string, substring of host), random (bool)
19 | *
20 | * @since 1.5.3
21 | */
22 |
23 | declare(strict_types=1);
24 |
25 | namespace SwapAds\Client\Backlinks;
26 |
27 | use SwapAds\Client\Api\RestClient;
28 | use SwapAds\Client\License\LicenseManager;
29 |
30 | final class BacklinksRenderer
31 | {
32 | public const MODE_LIST = 'list';
33 | public const MODE_CARD = 'card';
34 | public const MODE_TEXT = 'text';
35 |
36 | public const ALLOWED_MODES = [self::MODE_LIST, self::MODE_CARD, self::MODE_TEXT];
37 |
38 | public const DEFAULT_COUNT = 5;
39 | public const MIN_COUNT = 1;
40 | public const MAX_COUNT = 50;
41 |
42 | public const CACHE_GROUP = 'swapads_backlinks_render';
43 | public const CACHE_TTL = 60; // MVP-002/003 (2026-08-01): tightened from 300s. Operator-displayed but backlink-retraction propagation must surface quickly (per journey §4.3).
44 |
45 | /**
46 | * Render the full HTML output for an operator's approved-backlinks
47 | * block/shortcode. Falls back to empty string (NOT a warning) when
48 | * the site is unlicensed or the server has nothing for them — the
49 | * Gutenberg block reads as a placeholder in the editor, visitor-
50 | * facing pages render a no-op (avoids 401s reaching the front end).
51 | *
52 | * @param array<string, mixed> $args {
53 | * @type int $count 1-50, default 5
54 | * @type string $mode list|card|text, default list
55 | * @type string $audience filter by audience code, optional
56 | * @type string $category filter by host substring, optional
57 | * @type bool $random shuffle order, default false (newest first)
58 | * @type bool $cache use transients, default true
59 | * }
60 | *
61 | * @return string Rendered HTML, empty string on no-data.
62 | */
63 | public static function render(array $args = []): string
64 | {
65 | $count = self::clampCount((int) ($args['count'] ?? self::DEFAULT_COUNT));
66 | $mode = self::normalizeMode((string) ($args['mode'] ?? self::MODE_LIST));
67 | $audience = trim((string) ($args['audience'] ?? ''));
68 | $category = trim((string) ($args['category'] ?? ''));
69 | $random = (bool) ($args['random'] ?? false);
70 | $cache = (bool) ($args['cache'] ?? true);
71 |
72 | $cacheKey = self::cacheKey($count, $mode, $audience, $category, $random);
73 | if ($cache) {
74 | $cached = get_transient($cacheKey);
75 | if (is_string($cached)) {
76 | return $cached;
77 | }
78 | }
79 |
80 | $backlinks = self::fetchApproved($count, $audience, $category, $random);
81 | if (count($backlinks) === 0) {
82 | $html = '';
83 | } else {
84 | $html = self::renderBacklinks($backlinks, $mode);
85 | }
86 |
87 | if ($cache && $html !== '') {
88 | set_transient($cacheKey, $html, self::CACHE_TTL);
89 | // MVP-002/003 (2026-08-01): track every written transient key so
90 | // Plugin::deactivate() can flush the transient table on Hostinger
91 | // (wp_cache_flush_group only flushes the in-memory object cache,
92 | // not the wp_options-stored transients).
93 | self::trackTransient($cacheKey);
94 | }
95 |
96 | return $html;
97 | }
98 |
99 | /**
100 | * Clear the per-(count,mode,audience,category,random) cache.
101 | * Called when the operator mutates their backlink set
102 | * (approve, reject, source-check status change).
103 | */
104 | public static function clearCache(): void
105 | {
106 | // F236: best-effort flush. WP transients don't have a
107 | // pattern-delete primitive, so we rely on TTL=60 to expire.
108 | // Operators see at most 1 min staleness after a change.
109 | wp_cache_flush_group(self::CACHE_GROUP);
110 | // MVP-002/003 (2026-08-01): also delete every tracked transient key
111 | // (covers the wp_options-stored transients on Hostinger).
112 | self::invalidateAll();
113 | }
114 |
115 | /**
116 | * Track a transient key written by this renderer so it can be
117 | * invalidated on demand. Stores the key in the $GLOBALS registry.
118 | *
119 | * MVP-002/003 (2026-08-01): needed because WP's wp_cache_flush_group
120 | * does NOT flush wp_options-stored transients (it only flushes the
121 | * in-memory object cache). For Hostinger/file-cache, the
122 | * invalidateAll() sweep is the only way to flush within a request
123 | * without waiting for TTL.
124 | *
125 | * @param string $key Transient key (full cache key, already hashed).
126 | */
127 | public static function trackTransient(string $key): void
128 | {
129 | if (!isset($GLOBALS['_swapads_backlinks_transient_keys']) || !is_array($GLOBALS['_swapads_backlinks_transient_keys'])) {
130 | $GLOBALS['_swapads_backlinks_transient_keys'] = [];
131 | }
132 | $GLOBALS['_swapads_backlinks_transient_keys'][$key] = true;
133 | }
134 |
135 | /**
136 | * Delete every tracked transient key.
137 | *
138 | * MVP-002/003 (2026-08-01): called from Plugin::deactivate() so the
139 | * next request (after re-activation) sees fresh data, not stale cached
140 | * HTML from the previous site lifecycle. Returns the count of deleted
141 | * keys for testability.
142 | *
143 | * @return int Number of transient keys deleted.
144 | */
145 | public static function invalidateAll(): int
146 | {
147 | $deleted = 0;
148 | $keys = $GLOBALS['_swapads_backlinks_transient_keys'] ?? [];
149 | if (is_array($keys)) {
150 | foreach (array_keys($keys) as $key) {
151 | if (is_string($key) && delete_transient($key)) {
152 | $deleted++;
153 | }
154 | }
155 | // Reset the registry after a full sweep.
156 | $GLOBALS['_swapads_backlinks_transient_keys'] = [];
157 | }
158 | return $deleted;
159 | }
160 |
161 | /**
162 | * Test-only: clear the tracked-transients registry without deleting
163 | * the underlying transients. Lets tests reset between cases.
164 | */
165 | public static function resetTracking(): void
166 | {
167 | $GLOBALS['_swapads_backlinks_transient_keys'] = [];
168 | }
169 |
170 | /**
171 | * Build the cache key. Stable across requests for the same
172 | * operator + filter combination.
173 | */
174 | private static function cacheKey(int $count, string $mode, string $audience, string $category, bool $random): string
175 | {
176 | $license = (string) LicenseManager::key();
177 | $site = (string) (function_exists('home_url') ? home_url() : '');
178 | $hash = substr(md5($count . '|' . $mode . '|' . $audience . '|' . $category . '|' . ($random ? '1' : '0')), 0, 12);
179 | return self::CACHE_GROUP . '_' . substr(md5($license . '|' . $site), 0, 12) . '_' . $hash;
180 | }
181 |
182 | /**
183 | * Pull approved backlinks from the server's REST endpoint.
184 | * Returns [] on transport/auth failure (the operator sees nothing
185 | * rather than a 500 on the front end).
186 | *
187 | * @return array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}>
188 | */
189 | private static function fetchApproved(int $count, string $audience, string $category, bool $random): array
190 | {
191 | $licenseKey = (string) LicenseManager::key();
192 | if ($licenseKey === '') {
193 | return [];
194 | }
195 |
196 | $queryArgs = [
197 | 'count' => $count,
198 | 'random' => $random ? '1' : '0',
199 | ];
200 | if ($audience !== '') {
201 | $queryArgs['audience'] = $audience;
202 | }
203 | if ($category !== '') {
204 | $queryArgs['category'] = $category;
205 | }
206 |
207 | $client = RestClient::fromOption();
208 | $response = $client->get('/backlinks/approved', $queryArgs);
209 | if (!is_array($response)) {
210 | return [];
211 | }
212 | $backlinks = $response['backlinks'] ?? null;
213 | if (!is_array($backlinks)) {
214 | return [];
215 | }
216 |
217 | $normalized = [];
218 | foreach ($backlinks as $row) {
219 | if (!is_array($row)) {
220 | continue;
221 | }
222 | $url = (string) ($row['url'] ?? '');
223 | $anchor = (string) ($row['anchor_text'] ?? '');
224 | if ($url === '' || $anchor === '') {
225 | continue;
226 | }
227 | $normalized[] = [
228 | 'id' => (int) ($row['id'] ?? 0),
229 | 'url' => $url,
230 | 'anchor_text' => $anchor,
231 | 'rel_attribute' => (string) ($row['rel_attribute'] ?? 'dofollow'),
232 | ];
233 | }
234 | return $normalized;
235 | }
236 |
237 | /**
238 | * Render the inner HTML for the given backlinks + mode.
239 | * The outer `<div class="swapads-backlinks swapads-backlinks-{$mode}">`
240 | * is always present so CSS targeting works consistently.
241 | *
242 | * @param array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}> $backlinks
243 | */
244 | private static function renderBacklinks(array $backlinks, string $mode): string
245 | {
246 | $out = '<div class="swapads-backlinks swapads-backlinks-' . esc_attr($mode) . '">';
247 | foreach ($backlinks as $link) {
248 | $url = (string) $link['url'];
249 | $anchor = (string) $link['anchor_text'];
250 | $rel = (string) $link['rel_attribute'];
251 | $host = (string) wp_parse_url($url, PHP_URL_HOST);
252 |
253 | $id = (int) $link['id'];
254 | $out .= self::renderOne($id, $url, $anchor, $rel, $host, $mode);
255 | }
256 | $out .= '</div>';
257 | return $out;
258 | }
259 |
260 | /**
261 | * Render a single backlink in the requested mode.
262 | * Extracted so the test suite can assert per-row markup without
263 | * setting up the full wrapper.
264 | */
265 | private static function renderOne(int $id, string $url, string $anchor, string $rel, string $host, string $mode): string
266 | {
267 | $safeUrl = esc_url($url);
268 | $safeAnchor = esc_html($anchor);
269 | $safeRel = esc_attr($rel);
270 | $safeHost = $host !== '' ? esc_html($host) : '';
271 | // F238: backlink_id is what the JS click handler reads via data-swapads-sid
272 | // to enqueue click events. Without this, F238 statistics don't track.
273 | $dataAttr = $id > 0 ? ' data-swapads-sid="' . esc_attr((string) $id) . '"' : '';
274 |
275 | switch ($mode) {
276 | case self::MODE_CARD:
277 | $html = '<div class="swapads-backlink-card">';
278 | $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
279 | if ($safeHost !== '') {
280 | $html .= '<span class="swapads-backlink-host">' . $safeHost . '</span>';
281 | }
282 | $html .= '</div>';
283 | return $html;
284 |
285 | case self::MODE_TEXT:
286 | return '<a class="swapads-backlink swapads-backlink-inline" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a> ';
287 |
288 | case self::MODE_LIST:
289 | default:
290 | $html = '<div class="swapads-backlink-item">';
291 | $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
292 | if ($safeHost !== '') {
293 | $html .= ' <span class="swapads-backlink-host">(' . $safeHost . ')</span>';
294 | }
295 | $html .= '</div>';
296 | return $html;
297 | }
298 | }
299 |
300 | /**
301 | * Clamp the requested count to the allowed range.
302 | */
303 | public static function clampCount(int $count): int
304 | {
305 | if ($count < self::MIN_COUNT) {
306 | return self::MIN_COUNT;
307 | }
308 | if ($count > self::MAX_COUNT) {
309 | return self::MAX_COUNT;
310 | }
311 | return $count;
312 | }
313 |
314 | /**
315 | * Normalize the mode to one of the allowed values. Defaults to
316 | * MODE_LIST when the input is unknown.
317 | */
318 | public static function normalizeMode(string $mode): string
319 | {
320 | $mode = strtolower(trim($mode));
321 | if (in_array($mode, self::ALLOWED_MODES, true)) {
322 | return $mode;
323 | }
324 | return self::MODE_LIST;
325 | }
326 | }