Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.46% |
100 / 107 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
| BacklinksRenderer | |
93.46% |
100 / 107 |
|
72.73% |
8 / 11 |
46.59 | |
0.00% |
0 / 1 |
| render | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
6.01 | |||
| clearCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| trackTransient | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| invalidateAll | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| resetTracking | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| cacheKey | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| fetchApproved | |
84.85% |
28 / 33 |
|
0.00% |
0 / 1 |
11.42 | |||
| renderBacklinks | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| renderOne | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
9 | |||
| clampCount | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| normalizeMode | |
100.00% |
4 / 4 |
|
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 | } |