| 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 |
|