-
Notifications
You must be signed in to change notification settings - Fork 381
Expand file tree
/
Copy pathPairedRouting.php
More file actions
1176 lines (1049 loc) · 36.7 KB
/
PairedRouting.php
File metadata and controls
1176 lines (1049 loc) · 36.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* Class PairedRouting.
*
* @package AmpProject\AmpWP
*/
namespace AmpProject\AmpWP;
use AMP_Options_Manager;
use AMP_Theme_Support;
use AmpProject\AmpWP\DevTools\CallbackReflection;
use AMP_Post_Type_Support;
use AmpProject\AmpWP\Infrastructure\Injector;
use AmpProject\AmpWP\Infrastructure\Registerable;
use AmpProject\AmpWP\Infrastructure\Service;
use AmpProject\AmpWP\Admin\ReaderThemes;
use AmpProject\AmpWP\PairedUrlStructure\LegacyReaderUrlStructure;
use AmpProject\AmpWP\PairedUrlStructure\LegacyTransitionalUrlStructure;
use AmpProject\AmpWP\PairedUrlStructure\QueryVarUrlStructure;
use AmpProject\AmpWP\PairedUrlStructure\PathSuffixUrlStructure;
use WP_Query;
use WP_Rewrite;
use WP;
use WP_Hook;
use WP_Post;
use WP_Term;
use WP_Term_Query;
use WP_User;
/**
* Service for routing users to and from paired AMP URLs.
*
* @package AmpProject\AmpWP
* @since 2.1
* @internal
*/
final class PairedRouting implements Service, Registerable {
/**
* Paired URL structures.
*
* Mapping of the option key to the corresponding paired URL structure class.
*
* @var string[]
*/
const PAIRED_URL_STRUCTURES = [
Option::PAIRED_URL_STRUCTURE_QUERY_VAR => QueryVarUrlStructure::class,
Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX => PathSuffixUrlStructure::class,
Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL => LegacyTransitionalUrlStructure::class,
Option::PAIRED_URL_STRUCTURE_LEGACY_READER => LegacyReaderUrlStructure::class,
];
/**
* Custom paired URL structure.
*
* This involves a site adding the necessary filters to implement their own paired URL structure.
*
* @var string
*/
const PAIRED_URL_STRUCTURE_CUSTOM = 'custom';
/**
* Key for AMP paired examples.
*
* @see amp_get_slug()
* @var string
*/
const PAIRED_URL_EXAMPLES = 'paired_url_examples';
/**
* Key for the AMP slug.
*
* @see amp_get_slug()
* @var string
*/
const AMP_SLUG = 'amp_slug';
/**
* REST API field name for entities already using the AMP slug as name.
*
* @see amp_get_slug()
* @var string
*/
const ENDPOINT_PATH_SLUG_CONFLICTS = 'endpoint_path_slug_conflicts';
/**
* REST API field name for whether permalinks are being used in rewrite rules.
*
* @see WP_Rewrite::using_permalinks()
* @var string
*/
const REWRITE_USING_PERMALINKS = 'rewrite_using_permalinks';
/**
* Key for the custom paired structure sources.
*
* @var string
*/
const CUSTOM_PAIRED_ENDPOINT_SOURCES = 'custom_paired_endpoint_sources';
/**
* Action which is triggered when the late-defined slug needs to be updated in options.
*
* @var string
*/
const ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION = 'amp_update_late_defined_slug_option';
/**
* Paired URL service.
*
* @var PairedUrl
*/
private $paired_url;
/**
* Paired URL structure.
*
* @var PairedUrlStructure
*/
private $paired_url_structure;
/**
* Callback reflection.
*
* @var CallbackReflection
*/
private $callback_reflection;
/**
* AMP slug customization watcher.
*
* @var AmpSlugCustomizationWatcher
*/
private $amp_slug_customization_watcher;
/**
* Plugin registry.
*
* @var PluginRegistry
*/
private $plugin_registry;
/**
* Injector.
*
* @var Injector
*/
private $injector;
/**
* Whether the request had the /amp/ endpoint suffix.
*
* @var bool
*/
private $did_request_endpoint;
/**
* Current nesting level for the request.
*
* This is used to capture cases where `WP::parse_request()` is called from inside of a `request`
* filter, a case of a nested/recursive request. It is similar in concept to `ob_get_level()` for
* output buffering.
*
* @var int
*/
private $current_request_nesting_level = 0;
/**
* Original environment variables that were rewritten before parsing the request.
*
* @see PairedRouting::detect_endpoint_in_environment()
* @see PairedRouting::restore_path_endpoint_in_environment()
* @var array
*/
private $suspended_environment_variables = [];
/**
* PairedRouting constructor.
*
* @param Injector $injector Injector.
* @param CallbackReflection $callback_reflection Callback reflection.
* @param PluginRegistry $plugin_registry Plugin registry.
* @param PairedUrl $paired_url Paired URL service.
* @param AmpSlugCustomizationWatcher $amp_slug_customization_watcher AMP slug customization watcher.
*/
public function __construct( Injector $injector, CallbackReflection $callback_reflection, PluginRegistry $plugin_registry, PairedUrl $paired_url, AmpSlugCustomizationWatcher $amp_slug_customization_watcher ) {
$this->injector = $injector;
$this->callback_reflection = $callback_reflection;
$this->plugin_registry = $plugin_registry;
$this->paired_url = $paired_url;
$this->amp_slug_customization_watcher = $amp_slug_customization_watcher;
}
/**
* Register.
*/
public function register() {
add_filter( 'amp_rest_options_schema', [ $this, 'filter_rest_options_schema' ] );
add_filter( 'amp_rest_options', [ $this, 'filter_rest_options' ] );
add_filter( 'amp_default_options', [ $this, 'filter_default_options' ], 10, 2 );
add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 );
add_action( self::ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION, [ $this, 'update_late_defined_slug_option' ] );
add_action( AmpSlugCustomizationWatcher::LATE_DETERMINATION_ACTION, [ $this, 'check_stale_late_defined_slug_option' ] );
add_action( 'template_redirect', [ $this, 'redirect_extraneous_paired_endpoint' ], 9 );
// Priority 7 needed to run before PluginSuppression::initialize() at priority 8.
add_action( 'plugins_loaded', [ $this, 'initialize_paired_request' ], 7 );
}
/**
* Get the late defined slug, or null if it was not defined late.
*
* @return string|null Slug or null.
*/
public function get_late_defined_slug() {
return $this->amp_slug_customization_watcher->did_customize_late() ? amp_get_slug() : null;
}
/**
* Update late-defined slug option.
*/
public function update_late_defined_slug_option() {
AMP_Options_Manager::update_option( Option::LATE_DEFINED_SLUG, $this->get_late_defined_slug() );
}
/**
* Check whether the late-defined slug option is stale and a single event needs to be scheduled to update it.
*/
public function check_stale_late_defined_slug_option() {
$late_defined_slug = $this->get_late_defined_slug();
if ( AMP_Options_Manager::get_option( Option::LATE_DEFINED_SLUG ) !== $late_defined_slug ) {
wp_schedule_single_event( time(), self::ACTION_UPDATE_LATE_DEFINED_SLUG_OPTION );
}
}
/**
* Get the paired URL structure.
*
* @return PairedUrlStructure Paired URL structure.
*/
public function get_paired_url_structure() {
if ( ! $this->paired_url_structure instanceof PairedUrlStructure ) {
/**
* Filters to allow a custom paired URL structure to be used.
*
* @param string $structure_class Paired URL structure class.
*/
$structure_class = apply_filters( 'amp_custom_paired_url_structure', null );
if ( ! $structure_class || ! is_subclass_of( $structure_class, PairedUrlStructure::class ) ) {
$structure_slug = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE );
if ( array_key_exists( $structure_slug, self::PAIRED_URL_STRUCTURES ) ) {
$structure_class = self::PAIRED_URL_STRUCTURES[ $structure_slug ];
} else {
$structure_class = QueryVarUrlStructure::class;
}
}
$this->paired_url_structure = $this->injector->make( $structure_class );
}
return $this->paired_url_structure;
}
/**
* Filter the REST options schema to add items.
*
* @param array $schema Schema.
* @return array Schema.
*/
public function filter_rest_options_schema( $schema ) {
return array_merge(
$schema,
[
Option::PAIRED_URL_STRUCTURE => [
'type' => 'string',
'enum' => array_keys( self::PAIRED_URL_STRUCTURES ),
],
self::PAIRED_URL_EXAMPLES => [
'type' => 'object',
'readonly' => true,
],
self::AMP_SLUG => [
'type' => 'string',
'readonly' => true,
],
self::ENDPOINT_PATH_SLUG_CONFLICTS => [
'type' => 'object',
'readonly' => true,
],
self::REWRITE_USING_PERMALINKS => [
'type' => 'boolean',
'readonly' => true,
],
]
);
}
/**
* Filter the REST options to add items.
*
* @param array $options Options.
* @return array Options.
*/
public function filter_rest_options( $options ) {
$options[ self::AMP_SLUG ] = amp_get_slug();
if ( $this->has_custom_paired_url_structure() ) {
$options[ Option::PAIRED_URL_STRUCTURE ] = self::PAIRED_URL_STRUCTURE_CUSTOM;
} else {
$options[ Option::PAIRED_URL_STRUCTURE ] = AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE );
// Handle edge case where an unrecognized paired URL structure was saved.
if ( ! in_array( $options[ Option::PAIRED_URL_STRUCTURE ], array_keys( self::PAIRED_URL_STRUCTURES ), true ) ) {
$defaults = $this->filter_default_options( [], $options );
$options[ Option::PAIRED_URL_STRUCTURE ] = $defaults[ Option::PAIRED_URL_STRUCTURE ];
}
}
$options[ self::PAIRED_URL_EXAMPLES ] = $this->get_paired_url_examples();
$options[ self::CUSTOM_PAIRED_ENDPOINT_SOURCES ] = $this->get_custom_paired_structure_sources();
$options[ self::ENDPOINT_PATH_SLUG_CONFLICTS ] = $this->get_endpoint_path_slug_conflicts();
$options[ self::REWRITE_USING_PERMALINKS ] = $this->is_using_permalinks();
return $options;
}
/**
* Get the entities that are already using the AMP slug.
*
* @return array|null Conflict data or null if there are no conflicts.
* @global WP_Rewrite $wp_rewrite
*/
public function get_endpoint_path_slug_conflicts() {
global $wp_rewrite;
$conflicts = [];
$amp_slug = amp_get_slug();
$post_query = new WP_Query(
[
'post_type' => 'any',
'name' => $amp_slug,
'posts_per_page' => 100,
]
);
if ( $post_query->post_count > 0 ) {
$conflicts['posts'] = array_map(
static function ( WP_Post $post ) {
$post_type = get_post_type_object( $post->post_type );
return [
'id' => $post->ID,
'edit_link' => get_edit_post_link( $post->ID, 'raw' ),
'title' => $post->post_title,
'post_type' => $post->post_type,
'label' => isset( $post_type->labels->singular_name )
? $post_type->labels->singular_name
: null,
];
},
$post_query->posts
);
}
$term_query = new WP_Term_Query(
[
'slug' => $amp_slug,
'hide_empty' => false,
]
);
if ( $term_query->terms ) {
$conflicts['terms'] = array_map(
static function ( WP_Term $term ) {
$taxonomy = get_taxonomy( $term->taxonomy );
return [
'id' => $term->term_id,
'edit_link' => get_edit_term_link( $term->term_id, $term->taxonomy ),
'taxonomy' => $term->taxonomy,
'name' => $term->name,
'label' => isset( $taxonomy->labels->singular_name )
? $taxonomy->labels->singular_name
: null,
];
},
$term_query->terms
);
}
$user = get_user_by( 'slug', $amp_slug );
if ( $user instanceof WP_User ) {
$conflicts['user'] = [
'id' => $user->ID,
'edit_link' => get_edit_user_link( $user->ID ),
'name' => $user->display_name,
];
}
foreach ( get_post_types( [], 'objects' ) as $post_type ) {
if (
$amp_slug === $post_type->query_var
||
( isset( $post_type->rewrite['slug'] ) && $post_type->rewrite['slug'] === $amp_slug )
) {
$conflicts['post_type'] = [
'name' => $post_type->name,
'label' => isset( $post_type->labels->name )
? $post_type->labels->name
: null,
];
break;
}
}
foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) {
if (
$amp_slug === $taxonomy->query_var
||
( isset( $taxonomy->rewrite['slug'] ) && $taxonomy->rewrite['slug'] === $amp_slug )
) {
$conflicts['taxonomy'] = [
'name' => $taxonomy->name,
'label' => isset( $taxonomy->labels->name )
? $taxonomy->labels->name
: null,
];
break;
}
}
foreach ( $wp_rewrite->endpoints as $endpoint ) {
if ( isset( $endpoint[1] ) && $amp_slug === $endpoint[1] ) {
$conflicts['rewrite'][] = 'endpoint';
break;
}
}
foreach (
[
'author_base',
'comments_base',
'search_base',
'pagination_base',
'feed_base',
'comments_pagination_base',
]
as
$key
) {
if ( isset( $wp_rewrite->$key ) && $amp_slug === $wp_rewrite->$key ) {
$conflicts['rewrite'][] = $key;
}
}
if ( empty( $conflicts ) ) {
return null;
}
return $conflicts;
}
/**
* Add paired hooks.
*/
public function initialize_paired_request() {
if ( amp_is_canonical() ) {
return;
}
// Run necessary logic to properly route a request using the registered paired URL structures.
$this->detect_endpoint_in_environment();
add_filter( 'do_parse_request', [ $this, 'extract_endpoint_from_environment_before_parse_request' ], PHP_INT_MAX );
add_filter( 'request', [ $this, 'filter_request_after_endpoint_extraction' ] );
add_action( 'parse_request', [ $this, 'restore_path_endpoint_in_environment' ], defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : ~PHP_INT_MAX ); // phpcs:ignore PHPCompatibility.Constants.NewConstants.php_int_minFound
// Reserve the 'amp' slug for paired URL structures that use paths.
if ( $this->is_using_path_suffix() ) {
// Note that the wp_unique_term_slug filter does not work in the same way. It will only be applied if there
// is actually a duplicate, whereas the wp_unique_post_slug filter applies regardless.
add_filter( 'wp_unique_post_slug', [ $this, 'filter_unique_post_slug' ], 10, 4 );
}
add_action( 'parse_query', [ $this, 'correct_query_when_is_front_page' ] );
add_action( 'wp', [ $this, 'add_paired_request_hooks' ] );
add_action( 'admin_notices', [ $this, 'add_permalink_settings_notice' ] );
}
/**
* Determine whether the paired URL structure is using a path suffix (including the legacy Reader structure).
*
* @return bool
*/
public function is_using_path_suffix() {
return in_array(
AMP_Options_Manager::get_option( Option::PAIRED_URL_STRUCTURE ),
[
Option::PAIRED_URL_STRUCTURE_PATH_SUFFIX,
Option::PAIRED_URL_STRUCTURE_LEGACY_READER,
],
true
);
}
/**
* Detect the paired endpoint from the PATH_INFO or REQUEST_URI.
*
* This is necessary to avoid needing to rely on WordPress's rewrite rules to identify AMP requests.
* Rewrite rules are not suitable because rewrite endpoints can't be used across all URLs,
* and the request is parsed too late in order to switch to the Reader theme.
*
* The environment variables containing the endpoint are scrubbed of it during `WP::parse_request()`
* by means of the `PairedRouting::extract_endpoint_from_environment_before_parse_request()` method which runs
* at the `do_parse_request` filter.
*
* @see PairedRouting::extract_endpoint_from_environment_before_parse_request()
*/
public function detect_endpoint_in_environment() {
$this->did_request_endpoint = false;
// Detect and purge the AMP endpoint from the request.
foreach ( [ 'REQUEST_URI', 'PATH_INFO' ] as $var_name ) {
if ( empty( $_SERVER[ $var_name ] ) ) {
continue;
}
$paired_url_structure = $this->get_paired_url_structure();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$old_path = wp_unslash( $_SERVER[ $var_name ] ); // Because of wp_magic_quotes().
if ( ! $paired_url_structure->has_endpoint( $old_path ) ) {
continue;
}
$new_path = $paired_url_structure->remove_endpoint( $old_path );
$this->suspended_environment_variables[ $var_name ] = [ $old_path, $new_path ];
$this->did_request_endpoint = true;
}
}
/**
* Override environment before parsing the request.
*
* This happens at the beginning of `WP::parse_request()` and then it is reset when it finishes
* via the `PairedRouting::restore_path_endpoint_in_environment()` method at the `parse_request`
* action.
*
* @see WP::parse_request()
*
* @param bool $do_parse_request Whether or not to parse the request.
* @return bool Passed-through argument.
*/
public function extract_endpoint_from_environment_before_parse_request( $do_parse_request ) {
// If request parsing was aborted, then there's nothing for us to do.
if ( ! $do_parse_request ) {
return $do_parse_request;
}
// Only do something if doing the outermost request. Note that this function runs with the
// latest priority in order to make sure that any other `do_parse_request` filter will have
// already applied, and thus we know that the request is indeed going to be parsed.
if (
$this->did_request_endpoint
&&
0 === $this->current_request_nesting_level
) {
foreach ( $this->suspended_environment_variables as $var_name => list( , $new_path ) ) {
$_SERVER[ $var_name ] = wp_slash( $new_path ); // Because of wp_magic_quotes().
}
}
// Increase the nesting level so we can prevent calling ourselves for any recursive calls
// to `WP::parse_request()` during the `request` filter.
$this->current_request_nesting_level++;
return $do_parse_request;
}
/**
* Filter the request to add the AMP query var if endpoint was detected in the environment.
*
* @param array $query_vars Query vars.
* @return array Query vars.
*/
public function filter_request_after_endpoint_extraction( $query_vars ) {
if ( $this->did_request_endpoint ) {
$query_vars[ amp_get_slug() ] = true;
}
return $query_vars;
}
/**
* Restore the path endpoint in environment.
*
* @see PairedRouting::detect_endpoint_in_environment()
*
* @param WP $wp WP object.
*/
public function restore_path_endpoint_in_environment( WP $wp ) {
// Since the request has finished the parsing which was detected above in the
// `PairedRouting::extract_endpoint_from_environment_before_parse_request()`
// method, now decrement the level.
$this->current_request_nesting_level--;
// Only run for the outermost/root request when an AMP endpoint was requested.
if (
! $this->did_request_endpoint
||
0 !== $this->current_request_nesting_level
) {
return;
}
foreach ( $this->suspended_environment_variables as $var_name => list( $old_path, ) ) {
$_SERVER[ $var_name ] = wp_slash( $old_path ); // Because of wp_magic_quotes().
}
$this->suspended_environment_variables = [];
// In case a plugin is looking at $wp->request to see if it is AMP, ensure the path endpoint is added.
// WordPress is not including it because it was removed in extract_endpoint_from_environment_before_parse_request.
$request_path = '/';
if ( $wp->request ) {
$request_path .= trailingslashit( $wp->request );
}
$endpoint_url = $this->add_endpoint( $request_path );
$request_path = wp_parse_url( $endpoint_url, PHP_URL_PATH );
$wp->request = trim( $request_path, '/' );
}
/**
* Filters the post slug to prevent conflicting with the 'amp' slug.
*
* @see wp_unique_post_slug()
*
* @param string $slug Slug.
* @param int $post_id Post ID.
* @param string $post_status The post status.
* @param string $post_type Post type.
* @return string Slug.
* @global \wpdb $wpdb WP DB.
*/
public function filter_unique_post_slug( $slug, $post_id, /** @noinspection PhpUnusedParameterInspection */ $post_status, $post_type ) {
global $wpdb;
$amp_slug = amp_get_slug();
if ( $amp_slug !== $slug ) {
return $slug;
}
$suffix = 2;
do {
$alt_slug = "$slug-$suffix";
$slug_check = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Logic adapted from wp_unique_post_slug().
$wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1",
$alt_slug,
$post_type,
$post_id
)
);
$suffix++;
} while ( $slug_check );
$slug = $alt_slug;
return $slug;
}
/**
* Add hooks based for AMP pages and other hooks for non-AMP pages.
*/
public function add_paired_request_hooks() {
if ( $this->has_endpoint() ) {
add_filter( 'old_slug_redirect_url', [ $this, 'maybe_add_paired_endpoint' ], 1000 );
add_filter( 'redirect_canonical', [ $this, 'maybe_add_paired_endpoint' ], 1000 );
if ( $this->is_using_path_suffix() ) {
// Filter priority of 0 to purge /amp/ before other filters manipulate it.
add_filter( 'get_pagenum_link', [ $this, 'filter_get_pagenum_link' ], 0 );
add_filter( 'redirect_canonical', [ $this, 'filter_redirect_canonical_to_fix_cpage_requests' ], 0 );
}
} else {
add_action( 'wp_head', 'amp_add_amphtml_link' );
}
}
/**
* Fix pagenum link when using a path suffix.
*
* When paired AMP URLs end in /amp/, on the blog index page a call to `the_posts_pagination()` will result in
* unexpected links being added to the page. For example, `get_pagenum_link(2)` will result in `/blog/amp/page/2/`
* instead of the expected `/blog/page/2/amp/`. And then, when on the 2nd page of results (`/blog/page/2/amp/`), a
* call to `get_pagenum_link(3)` will result in an even more unexpected link pointing to `/blog/page/2/amp/page/3/`
* instead of `/blog/page/3/amp/`, whereas `get_pagenum_link(1)` will return `/blog/page/2/amp/` as opposed to the
* expected `/blog/amp/`. Note that `get_pagenum_link()` is used as the `base` for `paginate_links()` in
* `get_the_posts_pagination()`, and it uses as its base `remove_query_arg('paged')` which returns the `REQUEST_URI`.
*
* @see get_pagenum_link()
* @see get_the_posts_pagination()
*
* @param string $link Pagenum link.
* @return string Fixed pagenum link.
* @global WP_Rewrite $wp_rewrite
*/
public function filter_get_pagenum_link( $link ) {
global $wp_rewrite;
// Only relevant when using permalinks.
if ( ! $wp_rewrite instanceof WP_Rewrite || ! $wp_rewrite->using_permalinks() ) {
return $link;
}
$delimiter = ':';
$pattern = $delimiter;
// If the current page is a paged request (e.g. /page/2/), then we need to first strip that out from the link.
if ( is_paged() ) {
$pattern .= sprintf(
'/%s/%d',
preg_quote( $wp_rewrite->pagination_base, $delimiter ),
get_query_var( 'paged' )
);
}
// Now we remove the AMP path segment followed by any additional paged path segments, if they are present.
// This matches paths like:
// * /blog/amp/
// * /blog/amp/page/2/
// As well as allowing for the lack of a trailing slash and/or the presence of query args and/or a hash target.
$pattern .= sprintf(
'/%s((/%s/\d+)?/?(\?.*?)?(#.*)?)$',
preg_quote( amp_get_slug(), ':' ),
preg_quote( $wp_rewrite->pagination_base, ':' )
);
$pattern .= $delimiter;
return preg_replace(
$pattern,
'$1',
$link
);
}
/**
* Add notice to permalink settings screen for where to customize the paired URL structure.
*/
public function add_permalink_settings_notice() {
if ( 'options-permalink' !== get_current_screen()->id ) {
return;
}
?>
<div class="notice notice-info">
<p>
<?php
echo wp_kses(
sprintf(
/* translators: %s is the URL to the settings screen */
__( 'To customize the structure of the paired AMP URLs (given the site is not using the Standard template mode), go to the <a href="%s">Paired URL Structure</a> section on the AMP settings screen.', 'amp' ),
esc_url( admin_url( add_query_arg( 'page', AMP_Options_Manager::OPTION_NAME, 'admin.php' ) ) . '#paired-url-structure' )
),
[ 'a' => array_fill_keys( [ 'href' ], true ) ]
);
?>
</p>
</div>
<?php
}
/**
* Fix up canonical redirect URLs to put the comments pagination base in the right place.
*
* @param string $redirect_url Canonical redirect URL.
* @return string Updated canonical URL.
* @global WP_Rewrite $wp_rewrite
*/
public function filter_redirect_canonical_to_fix_cpage_requests( $redirect_url ) {
global $wp_rewrite;
if (
! empty( $redirect_url )
&&
get_query_var( 'cpage' )
&&
$wp_rewrite instanceof WP_Rewrite
&&
! empty( $wp_rewrite->comments_pagination_base )
) {
$amp_slug = amp_get_slug();
$regex = sprintf(
":/(%s-[0-9]+)/%s/\\1(/*)($|\?|\#):",
preg_quote( $wp_rewrite->comments_pagination_base, ':' ),
preg_quote( $amp_slug, ':' )
);
$redirect_url = preg_replace( $regex, "/\\1/{$amp_slug}\\2\\3", $redirect_url );
}
return $redirect_url;
}
/**
* Determine whether permalinks are being used.
*
* @return bool If permalinks are enabled.
*/
public function is_using_permalinks() {
return ! empty( get_option( 'permalink_structure' ) );
}
/**
* Add default option.
*
* @param array $defaults Default options.
* @param array $options Current options.
* @return array Defaults.
*/
public function filter_default_options( $defaults, $options ) {
$value = Option::PAIRED_URL_STRUCTURE_QUERY_VAR;
if (
isset( $options[ Option::VERSION ], $options[ Option::THEME_SUPPORT ], $options[ Option::READER_THEME ] )
&&
version_compare( $options[ Option::VERSION ], '2.1', '<' )
) {
if (
AMP_Theme_Support::READER_MODE_SLUG === $options[ Option::THEME_SUPPORT ]
&&
ReaderThemes::DEFAULT_READER_THEME === $options[ Option::READER_THEME ]
&&
$this->is_using_permalinks()
) {
$value = Option::PAIRED_URL_STRUCTURE_LEGACY_READER;
} elseif ( AMP_Theme_Support::STANDARD_MODE_SLUG !== $options[ Option::THEME_SUPPORT ] ) {
$value = Option::PAIRED_URL_STRUCTURE_LEGACY_TRANSITIONAL;
}
}
$defaults[ Option::PAIRED_URL_STRUCTURE ] = $value;
$defaults[ Option::LATE_DEFINED_SLUG ] = null;
return $defaults;
}
/**
* Sanitize options.
*
* Note that in a REST API context this is redundant with the enum defined in the schema.
*
* @param array $options Existing options with already-sanitized values for updating.
* @param array $new_options Unsanitized options being submitted for updating.
* @return array Sanitized options.
*/
public function sanitize_options( $options, $new_options ) {
// Cache the AMP slug in options when it is defined late so that it can be referred to early at plugins_loaded.
$options[ Option::LATE_DEFINED_SLUG ] = $this->get_late_defined_slug();
if (
isset( $new_options[ Option::PAIRED_URL_STRUCTURE ] )
&&
in_array( $new_options[ Option::PAIRED_URL_STRUCTURE ], array_keys( self::PAIRED_URL_STRUCTURES ), true )
) {
$options[ Option::PAIRED_URL_STRUCTURE ] = $new_options[ Option::PAIRED_URL_STRUCTURE ];
}
return $options;
}
/**
* Determine a given URL is for a paired AMP request.
*
* If no URL is provided, then it will check whether WordPress has already parsed the AMP
* query var as part of the request. If still not present, then it will get the current URL
* and check if it has an endpoint.
*
* @param string $url URL to examine. If empty, will use the current URL.
* @return bool True if the AMP query parameter is set with the required value, false if not.
* @global WP_Query $wp_the_query
*/
public function has_endpoint( $url = '' ) {
if ( empty( $url ) ) {
// This is a shortcut to avoid needing to re-parse the current URL.
if ( $this->did_request_endpoint ) {
return true;
}
$slug = amp_get_slug();
// On frontend, continue support case where the query var has been (manually) set.
global $wp_the_query;
if (
$wp_the_query instanceof WP_Query
&&
false !== $wp_the_query->get( $slug, false )
) {
return true;
}
// When not in a frontend context (e.g. the Customizer), the query var is the only possibility.
if (
is_admin()
&&
isset( $_GET[ $slug ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
) {
return true;
}
$url = amp_get_current_url();
}
return $this->get_paired_url_structure()->has_endpoint( $url );
}
/**
* Turn a given URL into a paired AMP URL.
*
* @param string $url URL.
* @return string AMP URL.
*/
public function add_endpoint( $url ) {
return $this->get_paired_url_structure()->add_endpoint( $url );
}
/**
* Remove the paired AMP endpoint from a given URL.
*
* @param string $url URL.
* @return string URL with AMP stripped.
*/
public function remove_endpoint( $url ) {
return $this->get_paired_url_structure()->remove_endpoint( $url );
}
/**
* Determine whether a custom paired URL structure is being used.
*
* @return bool Whether custom paired URL structure is used.
*/
public function has_custom_paired_url_structure() {
return has_filter( 'amp_custom_paired_url_structure' );
}
/**
* Get paired URLs for all available structures.
*
* @param string $url URL.
* @return array Paired URLs keyed by structure.
*/
public function get_all_structure_paired_urls( $url ) {
$paired_urls = [];
foreach ( self::PAIRED_URL_STRUCTURES as $structure_slug => $structure_class ) {
/** @var PairedUrlStructure $structure */
$structure = $this->injector->make( $structure_class );
$paired_urls[ $structure_slug ] = $structure->add_endpoint( $url );
}
if ( $this->has_custom_paired_url_structure() ) {
$paired_urls[ self::PAIRED_URL_STRUCTURE_CUSTOM ] = $this->add_endpoint( $url );
}
return $paired_urls;
}
/**
* Get paired URL examples.
*
* @return array[] Keys are the structures, values are arrays of paired URLs using the structure.
*/
public function get_paired_url_examples() {
$supported_post_types = AMP_Post_Type_Support::get_supported_post_types();
$hierarchical_post_types = array_intersect(
$supported_post_types,
get_post_types( [ 'hierarchical' => true ] )
);
$chronological_post_types = array_intersect(
$supported_post_types,
get_post_types( [ 'hierarchical' => false ] )
);
$examples = [];
foreach ( [ $chronological_post_types, $hierarchical_post_types ] as $post_types ) {
if ( empty( $post_types ) ) {
continue;
}
$posts = get_posts(
[
'post_type' => $post_types,
'post_status' => 'publish',
]
);
foreach ( $posts as $post ) {
if ( count( AMP_Post_Type_Support::get_support_errors( $post ) ) !== 0 ) {
continue;
}
$paired_urls = $this->get_all_structure_paired_urls( get_permalink( $post ) );