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-external-links-manager.php

class-external-links-manager.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-external-links-manager.php

681 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * External Links Manager Class
5 *
6 * Render-time rewriting of outbound anchors: rel="nofollow" and
7 * target="_blank" (with the noopener that has to ride along), plus a
8 * per-domain exception list for partners and affiliates the site DOES want
9 * to pass equity to.
10 *
11 * Nothing here touches stored post content — the rewrite happens on
12 * `the_content`, so turning either toggle off restores the author's markup
13 * byte for byte.
14 *
15 * @package ThinkRank
16 * @subpackage SEO
17 * @since 2.5.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\SEO;
23
24 use DOMDocument;
25 use DOMElement;
26 use DOMXPath;
27
28 // Prevent direct access
29 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 /**
34 * External Links Manager Class
35 *
36 * @since 2.5.0
37 */
38 class External_Links_Manager extends Abstract_SEO_Manager {
39
40 /**
41 * URL schemes that never point at another site's page.
42 *
43 * `mailto:` and `tel:` are actions, not documents; `javascript:` and
44 * `data:` are not navigations we should be annotating either. All are
45 * left exactly as authored.
46 *
47 * @since 2.5.0
48 * @var string[]
49 */
50 private const IGNORED_SCHEMES = ['mailto', 'tel', 'sms', 'javascript', 'data', 'ftp'];
51
52 /**
53 * Memoized host of home_url(), lowercased and stripped of "www.".
54 *
55 * @since 2.5.0
56 * @var string|null
57 */
58 private ?string $home_host = null;
59
60 /**
61 * Memoized site settings for the current request.
62 *
63 * process_content() runs once per rendered post, and get_settings() is
64 * cached anyway — this just keeps the common "both toggles off" exit
65 * from touching the cache API on every filter pass.
66 *
67 * @since 2.5.0
68 * @var array|null
69 */
70 private ?array $settings_cache = null;
71
72 /**
73 * Constructor
74 *
75 * @since 2.5.0
76 */
77 public function __construct() {
78 parent::__construct('external_links');
79 }
80
81 /**
82 * Default settings (implements interface)
83 *
84 * Both toggles ship off: rewriting every outbound link is a deliberate
85 * editorial choice, not a sane default to impose on existing content.
86 *
87 * @since 2.5.0
88 *
89 * @param string $context_type The context type
90 * @return array Default settings
91 */
92 public function get_default_settings(string $context_type): array {
93 return [
94 'nofollow_external' => false,
95 'open_external_in_new_tab' => false,
96 'external_link_exceptions' => [],
97 ];
98 }
99
100 /**
101 * Keys coerced to real booleans on read.
102 *
103 * @since 2.5.0
104 *
105 * @return string[]
106 */
107 protected function boolean_setting_keys(): array {
108 return array_merge(
109 parent::boolean_setting_keys(),
110 ['nofollow_external', 'open_external_in_new_tab']
111 );
112 }
113
114 /**
115 * Settings schema (implements interface)
116 *
117 * @since 2.5.0
118 *
119 * @param string $context_type The context type to get schema for
120 * @return array Settings schema definition
121 */
122 public function get_settings_schema(string $context_type): array {
123 return [
124 'nofollow_external' => [
125 'type' => 'boolean',
126 'title' => __('Add nofollow to external links', 'thinkrank'),
127 'description' => __('Adds rel="nofollow" to links pointing at another domain.', 'thinkrank'),
128 'default' => false,
129 ],
130 'open_external_in_new_tab' => [
131 'type' => 'boolean',
132 'title' => __('Open external links in a new tab', 'thinkrank'),
133 'description' => __('Adds target="_blank" (and the required rel="noopener") to links pointing at another domain.', 'thinkrank'),
134 'default' => false,
135 ],
136 'external_link_exceptions' => [
137 'type' => 'array',
138 'title' => __('Exceptions', 'thinkrank'),
139 'description' => __('Domains that keep the markup the author wrote. Matched on host, subdomains included.', 'thinkrank'),
140 'items' => ['type' => 'string'],
141 'default' => [],
142 ],
143 ];
144 }
145
146 /**
147 * Validate settings (implements interface)
148 *
149 * @since 2.5.0
150 *
151 * @param array $settings Settings array to validate
152 * @return array Validation results
153 */
154 public function validate_settings(array $settings): array {
155 $validation = [
156 'valid' => true,
157 'errors' => [],
158 'warnings' => [],
159 'suggestions' => [],
160 'score' => 100,
161 ];
162
163 foreach (['nofollow_external', 'open_external_in_new_tab'] as $key) {
164 if (isset($settings[$key]) && !is_bool($settings[$key]) && !is_numeric($settings[$key])) {
165 $validation['valid'] = false;
166 $validation['errors'][] = sprintf(
167 /* translators: %s: setting key */
168 __('%s must be a boolean value.', 'thinkrank'),
169 $key
170 );
171 }
172 }
173
174 if (isset($settings['external_link_exceptions']) && !is_array($settings['external_link_exceptions'])) {
175 $validation['valid'] = false;
176 $validation['errors'][] = __('The exception list must be an array of domains.', 'thinkrank');
177 }
178
179 if (empty($settings['nofollow_external']) && empty($settings['open_external_in_new_tab'])) {
180 $validation['suggestions'][] = __('Both external link options are off, so outbound links render exactly as authored.', 'thinkrank');
181 }
182
183 return $validation;
184 }
185
186 /**
187 * Output data (implements interface)
188 *
189 * @since 2.5.0
190 *
191 * @param string $context_type The context type
192 * @param int|null $context_id Optional. Context ID
193 * @return array
194 */
195 public function get_output_data(string $context_type, ?int $context_id): array {
196 return $this->get_site_settings();
197 }
198
199 /**
200 * Whether either toggle is on.
201 *
202 * @since 2.5.0
203 *
204 * @return bool
205 */
206 public function is_active(): bool {
207 $settings = $this->get_site_settings();
208
209 return !empty($settings['nofollow_external']) || !empty($settings['open_external_in_new_tab']);
210 }
211
212 /**
213 * Rewrite external anchors in a block of rendered HTML.
214 *
215 * Returns the input untouched whenever there is nothing to do — both
216 * toggles off, no anchor in the markup, or a parse that failed — so the
217 * filter is a no-op rather than a risk on content it cannot handle.
218 *
219 * @since 2.5.0
220 *
221 * @param string $content Rendered HTML.
222 * @return string Rewritten HTML.
223 */
224 public function process_content(string $content): string {
225 // `<a ` would miss an anchor whose attributes start on the next line,
226 // which hand-written and page-builder markup does routinely.
227 if ($content === '' || !preg_match('/<a[\s>]/i', $content)) {
228 return $content;
229 }
230
231 $settings = $this->get_site_settings();
232 $nofollow = !empty($settings['nofollow_external']);
233 $new_tab = !empty($settings['open_external_in_new_tab']);
234
235 if (!$nofollow && !$new_tab) {
236 return $content;
237 }
238
239 $exceptions = $this->normalize_exceptions($settings['external_link_exceptions'] ?? []);
240
241 return $this->rewrite_anchors($content, $nofollow, $new_tab, $exceptions);
242 }
243
244 /**
245 * Parse, rewrite and re-serialize the anchors of an HTML fragment.
246 *
247 * DOMDocument rather than a regex: an attribute has to be READ (to keep
248 * what the author set) before it is written, and the shapes a real
249 * anchor takes — unquoted values, a `>` inside an attribute, nested
250 * markup — are exactly what regex gets wrong when it mutates.
251 *
252 * @since 2.5.0
253 *
254 * @param string $content HTML fragment.
255 * @param bool $nofollow Whether to add rel="nofollow".
256 * @param bool $new_tab Whether to add target="_blank".
257 * @param string[] $exceptions Normalized exception hosts.
258 * @return string
259 */
260 private function rewrite_anchors(string $content, bool $nofollow, bool $new_tab, array $exceptions): string {
261 // Attribute-level edits on the original bytes: no parse and
262 // re-serialize cycle, so nothing outside the anchors it touches can be
263 // rewritten at all.
264 if (class_exists('WP_HTML_Tag_Processor')) {
265 return $this->rewrite_anchors_with_tag_processor($content, $nofollow, $new_tab, $exceptions);
266 }
267
268 // WordPress 6.0/6.1 have no Tag Processor, and libxml's HTML parser
269 // case-folds element and attribute names. SVG is case-sensitive XML, so
270 // a round-trip turns `viewBox` into the ignored `viewbox` and
271 // `<linearGradient>` into an element that does not exist — a gradient
272 // reference then resolves to nothing and the graphic breaks. Leaving
273 // such content as authored is the only honest option here: a missing
274 // rel attribute is a smaller failure than a destroyed illustration.
275 if (stripos($content, '<svg') !== false) {
276 return $content;
277 }
278
279 return $this->rewrite_anchors_with_dom($content, $nofollow, $new_tab, $exceptions);
280 }
281
282 /**
283 * Rewrite anchors through WP_HTML_Tag_Processor.
284 *
285 * Anchors inside `<svg>` and `<math>` are skipped: foreign content has its
286 * own `<a>`, which is not an outbound HTML link and takes neither `rel` nor
287 * `target` the way this feature means them.
288 *
289 * @since 2.5.0
290 *
291 * @param string $content HTML fragment.
292 * @param bool $nofollow Whether to add rel="nofollow".
293 * @param bool $new_tab Whether to add target="_blank".
294 * @param string[] $exceptions Normalized exception hosts.
295 * @return string
296 */
297 private function rewrite_anchors_with_tag_processor(string $content, bool $nofollow, bool $new_tab, array $exceptions): string {
298 $tags = new \WP_HTML_Tag_Processor($content);
299 $foreign = 0;
300 $changed = false;
301
302 while ($tags->next_tag(['tag_closers' => 'visit'])) {
303 $tag = (string) $tags->get_tag();
304
305 if ('SVG' === $tag || 'MATH' === $tag) {
306 if ($tags->is_tag_closer()) {
307 $foreign = max(0, $foreign - 1);
308 } elseif (!$tags->has_self_closing_flag()) {
309 $foreign++;
310 }
311
312 continue;
313 }
314
315 if ($foreign > 0 || 'A' !== $tag || $tags->is_tag_closer()) {
316 continue;
317 }
318
319 $href = $tags->get_attribute('href');
320
321 if (!is_string($href) || !$this->is_external_url($href, $exceptions)) {
322 continue;
323 }
324
325 $rel = $tags->get_attribute('rel');
326 $target = $tags->get_attribute('target');
327
328 $updates = $this->attribute_updates(
329 is_string($rel) ? $rel : '',
330 is_string($target) ? $target : '',
331 $nofollow,
332 $new_tab
333 );
334
335 foreach ($updates as $name => $value) {
336 $tags->set_attribute($name, $value);
337 $changed = true;
338 }
339 }
340
341 return $changed ? $tags->get_updated_html() : $content;
342 }
343
344 /**
345 * Rewrite anchors through DOMDocument, for WordPress before 6.2.
346 *
347 * @since 2.5.0
348 *
349 * @param string $content HTML fragment.
350 * @param bool $nofollow Whether to add rel="nofollow".
351 * @param bool $new_tab Whether to add target="_blank".
352 * @param string[] $exceptions Normalized exception hosts.
353 * @return string
354 */
355 private function rewrite_anchors_with_dom(string $content, bool $nofollow, bool $new_tab, array $exceptions): string {
356 $dom = new DOMDocument();
357 $libxml = libxml_use_internal_errors(true);
358 $wrapper = '<div id="thinkrank-external-links-root">' . $content . '</div>';
359 // The XML declaration pins UTF-8 without mb_convert_encoding()'s
360 // HTML-ENTITIES mode, which is removed in PHP 8.2.
361 $loaded = $dom->loadHTML(
362 '<?xml encoding="utf-8" ?>' . $wrapper,
363 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
364 );
365
366 libxml_clear_errors();
367 libxml_use_internal_errors($libxml);
368
369 if (!$loaded) {
370 return $content;
371 }
372
373 $xpath = new DOMXPath($dom);
374 // getElementById() is unreliable on a DTD-less HTML fragment; the
375 // wrapper is found by attribute instead. With LIBXML_HTML_NOIMPLIED
376 // it is normally documentElement, but not on markup libxml chose to
377 // restructure.
378 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMDocument's own property name.
379 $root = $dom->documentElement;
380
381 if (!$root instanceof DOMElement || $root->getAttribute('id') !== 'thinkrank-external-links-root') {
382 $found = $xpath->query('//div[@id="thinkrank-external-links-root"]');
383 $root = ($found && $found->length > 0) ? $found->item(0) : null;
384 }
385
386 if (!$root instanceof DOMElement) {
387 return $content;
388 }
389
390 $anchors = $xpath->query('.//a[@href]', $root);
391
392 if ($anchors === false || $anchors->length === 0) {
393 return $content;
394 }
395
396 $changed = false;
397
398 foreach ($anchors as $anchor) {
399 if (!$anchor instanceof DOMElement) {
400 continue;
401 }
402
403 if (!$this->is_external_url($anchor->getAttribute('href'), $exceptions)) {
404 continue;
405 }
406
407 $changed = $this->apply_attributes($anchor, $nofollow, $new_tab) || $changed;
408 }
409
410 if (!$changed) {
411 return $content;
412 }
413
414 $html = '';
415
416 foreach ($root->childNodes as $child) {
417 $html .= $dom->saveHTML($child);
418 }
419
420 return $html;
421 }
422
423 /**
424 * Add the configured tokens to one anchor, preserving what is there.
425 *
426 * `rel` and `target` are appended to, never replaced: an author who
427 * wrote rel="sponsored" or target="_self" meant it, and a setting that
428 * silently overwrote them would be a data loss the user cannot see.
429 *
430 * @since 2.5.0
431 *
432 * @param DOMElement $anchor Anchor element.
433 * @param bool $nofollow Whether to add rel="nofollow".
434 * @param bool $new_tab Whether to add target="_blank".
435 * @return bool Whether the element was modified.
436 */
437 private function apply_attributes(DOMElement $anchor, bool $nofollow, bool $new_tab): bool {
438 $updates = $this->attribute_updates(
439 $anchor->getAttribute('rel'),
440 $anchor->getAttribute('target'),
441 $nofollow,
442 $new_tab
443 );
444
445 foreach ($updates as $name => $value) {
446 $anchor->setAttribute($name, $value);
447 }
448
449 return !empty($updates);
450 }
451
452 /**
453 * The attributes one external anchor needs written, given what it already
454 * carries. Empty when it already says everything the settings ask for.
455 *
456 * `rel` and `target` are appended to, never replaced: an author who wrote
457 * rel="sponsored" or target="_self" meant it.
458 *
459 * `noopener` rides along with a new tab only when the link actually opens
460 * one — ours or the author's. On a target="_self" link it protects
461 * nothing, and adding it would override in spirit the very attribute this
462 * method refuses to overwrite.
463 *
464 * @since 2.5.0
465 *
466 * @param string $rel Current rel attribute.
467 * @param string $target Current target attribute.
468 * @param bool $nofollow Whether to add rel="nofollow".
469 * @param bool $new_tab Whether to add target="_blank".
470 * @return array<string,string> Attribute name => new value.
471 */
472 private function attribute_updates(string $rel, string $target, bool $nofollow, bool $new_tab): array {
473 $updates = [];
474
475 if ($new_tab && trim($target) === '') {
476 $updates['target'] = '_blank';
477 $target = '_blank';
478 }
479
480 $rel_add = [];
481
482 if ($nofollow) {
483 $rel_add[] = 'nofollow';
484 }
485
486 if (strtolower(trim($target)) === '_blank') {
487 $rel_add[] = 'noopener';
488 }
489
490 if (empty($rel_add)) {
491 return $updates;
492 }
493
494 $existing = preg_split('/\s+/', trim($rel)) ?: [];
495 $existing = array_values(array_filter($existing, static fn($token) => $token !== ''));
496 $lower = array_map('strtolower', $existing);
497 $tokens = $existing;
498 $added = false;
499
500 foreach ($rel_add as $token) {
501 if (!in_array($token, $lower, true)) {
502 $tokens[] = $token;
503 $lower[] = $token;
504 $added = true;
505 }
506 }
507
508 if ($added) {
509 $updates['rel'] = implode(' ', $tokens);
510 }
511
512 return $updates;
513 }
514
515 /**
516 * Whether a href points at a different site and is not excepted.
517 *
518 * Relative paths, fragments, protocol-relative links to our own host and
519 * anything in IGNORED_SCHEMES all answer false.
520 *
521 * @since 2.5.0
522 *
523 * @param string $href Raw href attribute.
524 * @param string[] $exceptions Normalized exception hosts.
525 * @return bool
526 */
527 private function is_external_url(string $href, array $exceptions): bool {
528 $href = trim($href);
529
530 if ($href === '' || strpos($href, '#') === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) {
531 return false;
532 }
533
534 $scheme = strtolower((string) wp_parse_url($href, PHP_URL_SCHEME));
535
536 if ($scheme !== '' && in_array($scheme, self::IGNORED_SCHEMES, true)) {
537 return false;
538 }
539
540 $host = $this->normalize_host((string) wp_parse_url($href, PHP_URL_HOST));
541
542 // No host at all means a relative link — same site by definition.
543 if ($host === '') {
544 return false;
545 }
546
547 if ($host === $this->get_home_host()) {
548 return false;
549 }
550
551 foreach ($exceptions as $exception) {
552 if ($host === $exception || substr($host, -(strlen($exception) + 1)) === '.' . $exception) {
553 return false;
554 }
555 }
556
557 return true;
558 }
559
560 /**
561 * Normalize the exception list to comparable hosts.
562 *
563 * Accepts what a user actually types: a bare domain, a full URL, a
564 * leading "www.", trailing slashes, or a newline-separated blob.
565 *
566 * @since 2.5.0
567 *
568 * @param array|string $exceptions Stored exception list.
569 * @return string[]
570 */
571 public function normalize_exceptions($exceptions): array {
572 if (is_string($exceptions)) {
573 $exceptions = preg_split('/[\r\n,]+/', $exceptions) ?: [];
574 }
575
576 if (!is_array($exceptions)) {
577 return [];
578 }
579
580 $hosts = [];
581
582 foreach ($exceptions as $entry) {
583 if (!is_string($entry)) {
584 continue;
585 }
586
587 $entry = trim($entry);
588
589 if ($entry === '') {
590 continue;
591 }
592
593 if (strpos($entry, '//') !== false) {
594 $entry = (string) wp_parse_url($entry, PHP_URL_HOST);
595 } else {
596 // "example.com/path" — keep the host half only.
597 $entry = explode('/', $entry)[0];
598 }
599
600 $host = $this->normalize_host($entry);
601
602 if ($host !== '') {
603 $hosts[] = $host;
604 }
605 }
606
607 return array_values(array_unique($hosts));
608 }
609
610 /**
611 * Lowercase a host and drop a leading "www." so comparisons match.
612 *
613 * @since 2.5.0
614 *
615 * @param string $host Host name.
616 * @return string
617 */
618 private function normalize_host(string $host): string {
619 $host = strtolower(trim($host));
620
621 if ($host === '') {
622 return '';
623 }
624
625 // Strip a userinfo prefix and any port.
626 $at = strrpos($host, '@');
627 if ($at !== false) {
628 $host = substr($host, $at + 1);
629 }
630
631 $host = explode(':', $host)[0];
632
633 if (strpos($host, 'www.') === 0) {
634 $host = substr($host, 4);
635 }
636
637 return trim($host, '.');
638 }
639
640 /**
641 * Host of home_url(), normalized and memoized.
642 *
643 * @since 2.5.0
644 *
645 * @return string
646 */
647 private function get_home_host(): string {
648 if ($this->home_host === null) {
649 $this->home_host = $this->normalize_host((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
650 }
651
652 return $this->home_host;
653 }
654
655 /**
656 * Site-wide settings for this manager, memoized per request.
657 *
658 * @since 2.5.0
659 *
660 * @return array
661 */
662 private function get_site_settings(): array {
663 if ($this->settings_cache === null) {
664 $this->settings_cache = $this->get_settings('site');
665 }
666
667 return $this->settings_cache;
668 }
669
670 /**
671 * Drop the memoized settings so a save is visible without a new request.
672 *
673 * @since 2.5.0
674 *
675 * @return void
676 */
677 public function flush_runtime_cache(): void {
678 $this->settings_cache = null;
679 }
680 }
681