PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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-sitemap-stylesheet.php

class-sitemap-stylesheet.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-sitemap-stylesheet.php

590 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Serves the sitemap XSL stylesheets, branded with the site's own logo and colours.
4 *
5 * @package ThinkRank
6 * @since 2.7.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * The browser-facing stylesheet for published sitemaps.
19 *
20 * Until 2.7.0 the `<?xml-stylesheet?>` instruction pointed straight at
21 * `static/xsl/sitemap.xsl`, a file the web server hands over untouched. That is
22 * why the sitemap could not be branded: nothing of ours runs on that request,
23 * so no setting could reach it. The instruction now points here instead, and
24 * this answers it from PHP with the stored palette substituted in.
25 *
26 * Three consequences worth keeping in mind when changing any of this:
27 *
28 * - **The .xsl files stay complete and stock.** They are read as templates,
29 * but every brandable colour in them is a CSS custom property holding the
30 * value it has always held, so a sitemap still pointing at the old static
31 * URL renders exactly as it did. Substitution only ever overwrites a
32 * custom property declaration; it never introduces one.
33 * - **Colour changes need no regeneration.** The published XML holds a fixed
34 * URL, and the palette is read per request, so saving a colour is visible
35 * immediately on sitemaps written months ago.
36 * - **The URL is a query string on the home URL, not a pretty path.** A path
37 * only reaches WordPress when the rewrite rules send it there, which on an
38 * Apache-style host running Plain permalinks they do not. The same trap
39 * Instant Indexing documents for its key file (#247).
40 *
41 * @since 2.7.0
42 */
43 class Sitemap_Stylesheet {
44
45 /**
46 * Query variable naming the stylesheet to serve.
47 *
48 * @since 2.7.0
49 * @var string
50 */
51 public const QUERY_VAR = 'thinkrank_sitemap_xsl';
52
53 /**
54 * Stylesheet variants, mapped to their template basename.
55 *
56 * @since 2.7.0
57 * @var array<string, string>
58 */
59 private const VARIANTS = [
60 'sitemap' => 'sitemap.xsl',
61 'index' => 'sitemap-index.xsl',
62 ];
63
64 /**
65 * The stock palette, mirroring the `:root` block in both templates.
66 *
67 * Used to answer "what does an unset colour look like?" for the settings
68 * screen. The templates remain the source of truth for rendering: an unset
69 * colour substitutes nothing at all, rather than substituting these.
70 *
71 * @since 2.7.0
72 * @var array<string, string>
73 */
74 public const STOCK_PALETTE = [
75 'main' => '#667eea',
76 'accent' => '#764ba2',
77 ];
78
79 /**
80 * Where the logo goes in the rendered header.
81 *
82 * An XSLT processor strips stylesheet comments from its output, so this
83 * marker costs a site with no logo nothing: served un-substituted, it
84 * simply never reaches the page.
85 *
86 * @since 2.7.0
87 * @var string
88 */
89 private const LOGO_MARKER = '<!-- thinkrank:logo -->';
90
91 /**
92 * Register the request handler.
93 *
94 * @since 2.7.0
95 * @return void
96 */
97 public function init(): void {
98 add_filter('query_vars', [$this, 'register_query_var']);
99 add_action('parse_request', [$this, 'maybe_serve']);
100 }
101
102 /**
103 * Declare the query variable so WordPress keeps it.
104 *
105 * @since 2.7.0
106 * @param string[] $vars Registered public query vars.
107 * @return string[]
108 */
109 public function register_query_var(array $vars): array {
110 $vars[] = self::QUERY_VAR;
111
112 return $vars;
113 }
114
115 /**
116 * The URL a published sitemap points its `<?xml-stylesheet?>` at.
117 *
118 * Single source of truth: the prolog writer and the request matcher below
119 * both derive from this, so they cannot drift.
120 *
121 * @since 2.7.0
122 * @param string $variant One of the VARIANTS keys.
123 * @return string Absolute stylesheet URL.
124 */
125 public static function url(string $variant): string {
126 if (!isset(self::VARIANTS[$variant])) {
127 $variant = 'sitemap';
128 }
129
130 return add_query_arg(self::QUERY_VAR, $variant, home_url('/'));
131 }
132
133 /**
134 * Answer a stylesheet request.
135 *
136 * @since 2.7.0
137 * @param \WP $wp Current WordPress environment instance.
138 * @return void
139 */
140 public function maybe_serve($wp): void {
141 if (is_admin()) {
142 return;
143 }
144
145 $variant = isset($wp->query_vars[self::QUERY_VAR])
146 ? sanitize_key((string) $wp->query_vars[self::QUERY_VAR])
147 : '';
148
149 if ($variant === '' || !isset(self::VARIANTS[$variant])) {
150 return;
151 }
152
153 $settings = $this->settings();
154
155 // Styling is opt-in, and turning it off stops the prolog writing the
156 // instruction at all. Serving a stylesheet the site has switched off
157 // would only paint sitemaps published before the change.
158 if (empty($settings['enabled']) || empty($settings['enable_styling'])) {
159 status_header(404);
160 nocache_headers();
161 exit;
162 }
163
164 $xsl = $this->render($settings, $variant);
165
166 if ($xsl === '') {
167 status_header(404);
168 nocache_headers();
169 exit;
170 }
171
172 // Validated rather than timed. The URL in a published sitemap is fixed,
173 // so a freshness window is a window in which a colour the owner just
174 // saved is not the colour they see, with nothing they can do but wait
175 // it out; a plain `max-age` of an hour had exactly that effect while
176 // this was being built. Revalidating every time costs a 304.
177 $etag = '"' . md5($xsl) . '"';
178
179 header('Content-Type: text/xsl; charset=UTF-8');
180 header('X-Robots-Tag: noindex');
181 header('Cache-Control: public, max-age=0, must-revalidate');
182 header('ETag: ' . $etag);
183
184 $known = isset($_SERVER['HTTP_IF_NONE_MATCH'])
185 ? trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_IF_NONE_MATCH'])))
186 : '';
187
188 if ($known !== '' && $known === $etag) {
189 status_header(304);
190 exit;
191 }
192
193 status_header(200);
194
195 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XSL document assembled and escaped in render().
196 echo $xsl;
197 exit;
198 }
199
200 /**
201 * Build the stylesheet for a variant with the site's branding applied.
202 *
203 * @since 2.7.0
204 * @param array $settings Sitemap settings.
205 * @param string $variant One of the VARIANTS keys.
206 * @return string XSL document, or '' when the template is unreadable.
207 */
208 public function render(array $settings, string $variant): string {
209 if (!isset(self::VARIANTS[$variant])) {
210 return '';
211 }
212
213 $path = THINKRANK_PLUGIN_DIR . 'static/xsl/' . self::VARIANTS[$variant];
214
215 if (!is_readable($path)) {
216 return '';
217 }
218
219 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local read of a fixed file shipped with the plugin.
220 $xsl = (string) file_get_contents($path);
221
222 if ($xsl === '') {
223 return '';
224 }
225
226 foreach ($this->palette($settings) as $property => $value) {
227 $xsl = self::set_custom_property($xsl, $property, $value);
228 }
229
230 $logo = $this->logo_markup($settings);
231
232 if ($logo === '') {
233 // Take the whole line, not just the marker: an XSLT processor drops
234 // the comment either way, but leaving a stray indented blank line
235 // in a document people do read is needless.
236 return (string) preg_replace(
237 '/^[ \t]*' . preg_quote(self::LOGO_MARKER, '/') . '\R/m',
238 '',
239 $xsl,
240 1
241 );
242 }
243
244 return str_replace(self::LOGO_MARKER, $logo, $xsl);
245 }
246
247 /**
248 * The custom property overrides a site's chosen colours imply.
249 *
250 * A colour is never simply pasted everywhere it appears. Where it becomes
251 * a background the text on top is recomputed for it, and where it becomes
252 * text on the page's own white it is darkened until it is readable there.
253 * A branding feature that lets an owner pick pale yellow and hands back a
254 * sitemap nobody can read is worse than one with no branding at all.
255 *
256 * An unset colour contributes nothing, which is what keeps an untouched
257 * site rendering exactly as it did.
258 *
259 * @since 2.7.0
260 * @param array $settings Sitemap settings.
261 * @return array<string, string> Property name => value.
262 */
263 private function palette(array $settings): array {
264 $main = self::hex($settings['styling_color_main'] ?? '');
265 $accent = self::hex($settings['styling_color_accent'] ?? '');
266
267 if ($main === '' && $accent === '') {
268 return [];
269 }
270
271 $values = [];
272
273 if ($main !== '') {
274 // Backgrounds and rules take the colour as picked.
275 $values['--tr-brand-main'] = $main;
276 $values['--tr-brand-th'] = $main;
277 $values['--tr-brand-rule'] = $main;
278 $values['--tr-brand-note-bg'] = self::tint($main);
279
280 // Text on those backgrounds is chosen to sit on them.
281 $values['--tr-brand-th-text'] = self::readable_ink([$main]);
282
283 // Text on the page itself is darkened to stay legible on white.
284 $values['--tr-brand-link'] = self::readable_on_paper($main);
285 $values['--tr-brand-note'] = self::readable_on_paper($main);
286 }
287
288 if ($accent !== '') {
289 $values['--tr-brand-accent'] = $accent;
290 $values['--tr-brand-badge'] = $accent;
291 $values['--tr-brand-badge-text'] = self::readable_ink([$accent]);
292 $values['--tr-brand-link-hover'] = self::readable_on_paper($accent);
293 }
294
295 // The header is a gradient, so its text has to clear both stops. An
296 // unset colour still contributes the stock one it will render as.
297 $values['--tr-brand-on-brand'] = self::readable_ink([
298 $main !== '' ? $main : self::STOCK_PALETTE['main'],
299 $accent !== '' ? $accent : self::STOCK_PALETTE['accent'],
300 ]);
301
302 return $values;
303 }
304
305 /**
306 * Overwrite one custom property declaration, leaving everything else alone.
307 *
308 * Deliberately narrow: it rewrites the value of a `--tr-*` declaration that
309 * is already in the template and does nothing when that declaration is
310 * absent. A template edited past recognition therefore renders stock rather
311 * than broken.
312 *
313 * @since 2.7.0
314 * @param string $xsl Stylesheet source.
315 * @param string $property Custom property name, leading dashes included.
316 * @param string $value Replacement value.
317 * @return string
318 */
319 private static function set_custom_property(string $xsl, string $property, string $value): string {
320 return (string) preg_replace(
321 '/(' . preg_quote($property, '/') . '\s*:\s*)[^;]+/',
322 '${1}' . $value,
323 $xsl,
324 1
325 );
326 }
327
328 /**
329 * The `<img>` that replaces the logo marker, or '' to leave it stripped.
330 *
331 * @since 2.7.0
332 * @param array $settings Sitemap settings.
333 * @return string XML-safe markup.
334 */
335 private function logo_markup(array $settings): string {
336 if (empty($settings['styling_logo'])) {
337 return '';
338 }
339
340 $url = (string) ($settings['styling_logo_url'] ?? '');
341
342 // Nothing picked means the site icon, which is the logo a site has
343 // already told WordPress about.
344 if (trim($url) === '') {
345 $url = (string) get_site_icon_url(192);
346 }
347
348 // esc_url_raw() for the protocol allow-list, which is the part that
349 // matters for safety, and no display escaping of its own.
350 $url = esc_url_raw($url);
351
352 if ($url === '') {
353 return '';
354 }
355
356 // Escaped for XML explicitly rather than by esc_url()/esc_attr(). Those
357 // escape for HTML, where a bare `&` in a query string is tolerated; here
358 // the markup is spliced into an XML document, and one unescaped
359 // ampersand in a logo URL takes the whole sitemap view down rather than
360 // just looking wrong.
361 return sprintf(
362 '<img class="sitemap-logo" src="%s" alt="%s"/>',
363 self::xml_attr($url),
364 self::xml_attr((string) get_bloginfo('name'))
365 );
366 }
367
368 /**
369 * Escape a value for an XML attribute.
370 *
371 * @since 2.7.0
372 * @param string $value Raw value.
373 * @return string
374 */
375 private static function xml_attr(string $value): string {
376 return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
377 }
378
379 /**
380 * Black or white, whichever is legible on these backgrounds.
381 *
382 * A single background is answered by luminance alone: 0.45 sits between
383 * the luminance of a mid grey and a light one, which is where white text
384 * stops being readable.
385 *
386 * The header is a gradient, and a gradient can span a dark stop and a
387 * light one. Then no flat ink clears AA on both, and the luminance test
388 * alone answers the lightest stop while ignoring the other: navy to cyan
389 * picked near-black, which reads at 1.22:1 where the heading actually
390 * starts. For more than one stop the ink is therefore judged against its
391 * hardest stop, which is what this was always documented to do.
392 *
393 * @since 2.7.0
394 * @param string[] $colours Hex colours, `#rrggbb`.
395 * @return string `#ffffff` or `#111111`.
396 */
397 private static function readable_ink(array $colours): string {
398 $lightest = 0.0;
399
400 foreach ($colours as $colour) {
401 $lightest = max($lightest, self::luminance($colour));
402 }
403
404 $ink = $lightest > 0.45 ? '#111111' : '#ffffff';
405
406 if (count($colours) < 2) {
407 return $ink;
408 }
409
410 $other = '#111111' === $ink ? '#ffffff' : '#111111';
411
412 // Strictly better, never merely different: the alternative has to beat
413 // the luminance test's answer at its worst stop to displace it, so a
414 // gradient both inks handle keeps the ink it has always been given.
415 return self::worst_contrast($other, $colours) > self::worst_contrast($ink, $colours)
416 ? $other
417 : $ink;
418 }
419
420 /**
421 * The lowest WCAG contrast ratio an ink reaches across several backgrounds.
422 *
423 * @since 2.7.0
424 * @param string $ink Hex colour the text is drawn in.
425 * @param string[] $colours Hex backgrounds it has to sit on.
426 * @return float Contrast ratio, 1.0 when there is nothing to sit on.
427 */
428 private static function worst_contrast(string $ink, array $colours): float {
429 $worst = null;
430
431 foreach ($colours as $colour) {
432 $high = max(self::luminance($ink), self::luminance($colour));
433 $low = min(self::luminance($ink), self::luminance($colour));
434
435 $ratio = ($high + 0.05) / ($low + 0.05);
436 $worst = null === $worst ? $ratio : min($worst, $ratio);
437 }
438
439 return null === $worst ? 1.0 : $worst;
440 }
441
442 /**
443 * The same colour, darkened until it is readable as text on white.
444 *
445 * Links, and the note panel's body text, sit on the page's own background
446 * rather than on the brand colour, so a pale brand has to be brought down
447 * to meet it. Returned unchanged when it already clears WCAG AA.
448 *
449 * @since 2.7.0
450 * @param string $colour Hex colour, `#rrggbb`.
451 * @return string Hex colour.
452 */
453 private static function readable_on_paper(string $colour): string {
454 // Contrast against white is 1.05 / (L + 0.05); AA body text wants 4.5.
455 $ceiling = (1.05 / 4.5) - 0.05;
456
457 if (self::luminance($colour) <= $ceiling) {
458 return $colour;
459 }
460
461 $hex = ltrim($colour, '#');
462
463 if (strlen($hex) !== 6) {
464 return $colour;
465 }
466
467 $channels = [
468 (int) hexdec(substr($hex, 0, 2)),
469 (int) hexdec(substr($hex, 2, 2)),
470 (int) hexdec(substr($hex, 4, 2)),
471 ];
472
473 // Scale the channels down together so the hue survives the darkening,
474 // in small steps so the result clears the bar without overshooting it
475 // into something near-black the owner would not recognise.
476 for ($factor = 0.98; $factor > 0.0; $factor -= 0.02) {
477 $candidate = '#';
478
479 foreach ($channels as $channel) {
480 $candidate .= str_pad(dechex((int) round($channel * $factor)), 2, '0', STR_PAD_LEFT);
481 }
482
483 if (self::luminance($candidate) <= $ceiling) {
484 return $candidate;
485 }
486 }
487
488 return '#111111';
489 }
490
491 /**
492 * A colour mixed most of the way into white.
493 *
494 * Computed here rather than left to CSS `color-mix()`, so the value in the
495 * served stylesheet is a plain hex and the template stays renderable by
496 * anything that can read the stock one.
497 *
498 * @since 2.7.0
499 * @param string $colour Hex colour, `#rrggbb`.
500 * @return string Hex colour.
501 */
502 private static function tint(string $colour): string {
503 $hex = ltrim($colour, '#');
504
505 if (strlen($hex) !== 6) {
506 return $colour;
507 }
508
509 $out = '#';
510
511 foreach ([0, 2, 4] as $offset) {
512 $channel = (int) hexdec(substr($hex, $offset, 2));
513 $out .= str_pad(dechex((int) round(255 - ((255 - $channel) * 0.10))), 2, '0', STR_PAD_LEFT);
514 }
515
516 return $out;
517 }
518
519 /**
520 * WCAG relative luminance of a hex colour, 0 (black) to 1 (white).
521 *
522 * @since 2.7.0
523 * @param string $colour Hex colour, `#rrggbb`.
524 * @return float
525 */
526 private static function luminance(string $colour): float {
527 $hex = ltrim($colour, '#');
528
529 if (strlen($hex) === 3) {
530 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
531 }
532
533 if (strlen($hex) !== 6) {
534 return 0.0;
535 }
536
537 $channels = [];
538
539 foreach ([0, 2, 4] as $offset) {
540 $value = hexdec(substr($hex, $offset, 2)) / 255;
541 $channels[] = $value <= 0.03928
542 ? $value / 12.92
543 : pow(($value + 0.055) / 1.055, 2.4);
544 }
545
546 return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
547 }
548
549 /**
550 * A stored colour, normalized, or '' when it is not one.
551 *
552 * '' is the meaningful default: it means "substitute nothing", so the
553 * template's own value stands and an untouched site renders byte for byte
554 * as it did before this feature existed.
555 *
556 * @since 2.7.0
557 * @param mixed $value Stored setting value.
558 * @return string `#rrggbb`, or ''.
559 */
560 public static function hex($value): string {
561 if (!is_string($value)) {
562 return '';
563 }
564
565 $value = trim($value);
566
567 if ($value === '') {
568 return '';
569 }
570
571 if ($value[0] !== '#') {
572 $value = '#' . $value;
573 }
574
575 $colour = sanitize_hex_color(strtolower($value));
576
577 return is_string($colour) ? $colour : '';
578 }
579
580 /**
581 * Stored sitemap settings.
582 *
583 * @since 2.7.0
584 * @return array
585 */
586 private function settings(): array {
587 return (new Sitemap_Generator(false))->get_settings('site', null);
588 }
589 }
590