Repository navigation
Expand file tree
/
Copy pathrankready.php
More file actions
2112 lines (1940 loc) · 99.6 KB
/
Copy pathrankready.php
File metadata and controls
2112 lines (1940 loc) · 99.6 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
/**
* Plugin Name: RankReady – AI SEO, Schema, llms.txt, AEO and GEO for ChatGPT, Gemini and Perplexity
* Plugin URI: https://hostmy.blog/plugins/rankready/
* Description: Make your WordPress content readable by ChatGPT, Perplexity, Claude, Gemini, and Google AI Overviews. AI summaries, FAQ schema, llms.txt, Markdown endpoints, agent discovery headers, WebMCP, and crawler controls — in one plugin.
* Version: 1.4.0
* Requires at least: 6.9
* Requires PHP: 7.4
* Author: HostMyBlog
* Author URI: https://hostmy.blog
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: rankready-ai-llm-seo
* Domain Path: /languages
*/
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'rnrd_fs' ) ) {
// Create a helper function for easy SDK access.
function rnrd_fs() {
global $rnrd_fs;
if ( ! isset( $rnrd_fs ) ) {
$rnrd_fs_sdk = dirname( __FILE__ ) . '/vendor/freemius/start.php';
if ( ! file_exists( $rnrd_fs_sdk ) ) {
return false;
}
require_once $rnrd_fs_sdk;
$rnrd_fs = fs_dynamic_init( array(
'id' => '37729',
'slug' => 'rankready-ai-llm-seo',
'type' => 'plugin',
'public_key' => 'pk_4a3356e64068eb259388059c5c167',
'is_premium' => false,
/*
* ADD-ON MODEL. Free and Pro are two separate plugins, always.
*
* Free is the WordPress.org plugin and is the only thing that
* ever ships there. Pro is a SEPARATE plugin (`rankready-pro`)
* that requires Free to be active and extends it through hooks.
* Free is never replaced, never swapped for a paid build, and
* a paying user runs both plugins side by side.
*
* This is Aditya's standing decision, restated 2026-09-07. An
* earlier pass moved this to Freemius's premium-version model
* (one product, paid build replaces free) on the strength of the
* dashboard's "Paid version slug" field. That was the wrong read
* of a product decision that was never mine to make.
*
* `has_addons` is true so the SDK knows a separate paid product
* exists and the Account screen can surface its license.
*/
'has_addons' => true,
'has_paid_plans' => true,
'is_org_compliant' => true,
'menu' => array(
'slug' => 'rankready-ai-llm-seo',
'first-path' => 'admin.php?page=rankready-ai-llm-seo',
// Account and pricing must be reachable once plans exist,
// otherwise a buyer has nowhere to manage or start a license.
'account' => true,
'pricing' => true,
// Still no Add-Ons menu row. The add-on exists, but Upgrade
// Pro is the single destination we point people at, and two
// sidebar rows for one place is clutter. `has_addons` above
// is about the SDK knowing the product exists, not about
// growing the menu.
'addons' => false,
'contact' => false,
'support' => false,
),
) );
// Diagnostic telemetry is opt-in, never on by default. Forced opt-in
// is the most-complained-about Freemius behaviour and this plugin has
// already been rejected twice on review.
$rnrd_fs->add_filter( 'permission_diagnostic_default', '__return_false' );
$rnrd_fs->add_filter( 'permission_extensions_default', '__return_false' );
/*
* Deactivation survey, shown to EVERYONE.
*
* The SDK only offers it to users who opted in, which for a plugin
* whose telemetry is opt-out by default means almost nobody: the
* modal that appeared on deactivate was "Opt Out", not the feedback
* form. Forcing it on means the one moment somebody tells you why
* they are leaving is actually captured.
*
* It stays skippable. The form has a close button and a plain
* "Skip & Deactivate", so nobody is held hostage to answer, which is
* the version of this pattern that earns bad reviews.
*/
/*
* FREE ONLY. A paying customer who deactivates does not owe us a
* survey, and asking is the wrong move twice over: they already
* told us they valued this enough to pay, and a support
* conversation beats a radio button. If someone with a license
* turns the plugin off, that is a support signal, not research.
*
* `is_paying()` covers an active license; `is_trial()` covers
* someone mid-trial, who is also not a free user. Wrapped in a
* function_exists guard so a partially booted SDK cannot fatal
* the Plugins screen, which is the worst place to crash.
*/
$rnrd_fs->add_filter(
'show_deactivation_feedback_form',
function () use ( $rnrd_fs ) {
if ( ! method_exists( $rnrd_fs, 'is_paying' ) ) {
return true;
}
return ! ( $rnrd_fs->is_paying() || $rnrd_fs->is_trial() );
}
);
// Never hold up an uninstall on a network call. If Freemius is
// unreachable the plugin still deactivates.
$rnrd_fs->add_filter( 'deactivate_on_activation', '__return_false' );
/*
* The opt-in is offered ONCE, on the activation screen. It does not then
* follow the user around their own site.
*
* Freemius pins a sticky "We made a few tweaks to the plugin, Opt in to make
* ... better!" notice to the top of EVERY wp-admin screen for anyone who did
* not opt in: Dashboard, Plugins, Posts, Users, Settings, all of them, not
* just RankReady's. Verified in a running WordPress, on all four. It is
* dismissible, and it is still the single most-complained-about thing about
* Freemius, and WordPress.org's own guidelines say a plugin's notices belong
* on that plugin's screens.
*
* remove_sticky() is the SDK's own public API for this (class-freemius.php),
* not a hack around it. The opt-in itself is untouched: the activation screen
* still asks, Account still offers it, and anyone who already opted in is
* unaffected. What goes away is being asked again on every page forever
* after answering no once.
*
* IMPORTANT: NOT anonymous_mode. That would suppress the notice too, by skipping the
* opt-in entirely, and that is a business call about install analytics and
* the mailing list, not a UX fix. Aditya makes that one, not this file.
*/
add_action(
'admin_init',
function () use ( $rnrd_fs ) {
if ( method_exists( $rnrd_fs, 'remove_sticky' ) ) {
$rnrd_fs->remove_sticky( 'connect_account' );
}
},
5
);
// Sidebar reads "Upgrade Pro", not a bare "Upgrade" with an arrow.
//
// There is no `pricing_menu_title` filter in the SDK; the label comes
// from the translatable string keyed `upgrade` (class-freemius.php,
// get_pricing_cta_label). override_i18n is the supported way to change
// it. The arrow is a separate, unfiltered concatenation, so it has to
// be taken off the rendered submenu entry afterwards.
// Deferred to `init` on purpose. rnrd_fs() runs at file scope during
// plugin load, and calling __() there trips WordPress 6.7+'s
// _load_textdomain_just_in_time notice, which Plugin Check reports.
// Freemius registers the submenu at admin_menu priority 999999999,
// long after init, so the label is still in place when it renders.
add_action(
'init',
function () use ( $rnrd_fs ) {
$rnrd_fs->override_i18n( array( 'upgrade' => __( 'Upgrade Pro', 'rankready-ai-llm-seo' ) ) );
}
);
// Every Freemius upgrade link goes to OUR upgrade screen.
//
// GitHub issue #49: the "Upgrade Pro" link Freemius adds to the
// plugin row on plugins.php was built from get_upgrade_url(), so it
// pointed at admin.php?billing_cycle=annual&page=rankready-ai-llm-seo-pricing,
// the Freemius pricing page the sidebar entry was already rerouted
// away from (see the admin_menu rewrite of the `-pricing` submenu).
// pricing_url is the SDK's own filter at the end of pricing_url(),
// which get_upgrade_url() and get_trial_url() both go through, so
// this one hook covers the row link and every other Freemius CTA.
$rnrd_fs->add_filter(
'pricing_url',
function ( $url ) {
return function_exists( 'rnrd_upgrade_url' ) ? rnrd_upgrade_url( 'freemius' ) : $url;
}
);
}
return $rnrd_fs;
}
if ( false !== rnrd_fs() ) {
do_action( 'rnrd_fs_loaded' );
}
}
if ( ! function_exists( 'rnrd_repair_freemius_asset_url' ) ) {
/**
* Repair a Freemius stylesheet or script URL the SDK built from a filesystem path.
*
* GitHub issues #47 and #48: the deactivation feedback modal and the license
* activation modal rendered as plain markup below the admin footer. Their
* layout lives in the SDK's dialog-boxes.css, and on those installs that file
* 404'd. The cause is fs_asset_url() in the SDK. When the plugin folder is a
* symlink whose name differs from its target (a development checkout linked
* in as `rankready-ai-llm-seo` -> `.../rankready-free`), start.php cannot
* resolve the link, WP_FS__DIR stays the real path outside WP_PLUGIN_DIR, and
* fs_asset_url() falls through to its theme branch:
*
* /wp-content/themes//srv/src/rankready-free/vendor/freemius/assets/css/admin/dialog-boxes.css
*
* Reproduced in the lab 2026-09-29 with exactly that symlink. plugins_url()
* does resolve symlinked plugins (core registers their real paths at load),
* so the URL is rebuilt through it. A normal install never matches: its URLs
* never contain the absolute SDK path, so they are returned untouched.
*
* @param string|false $src Asset URL from the style/script loader.
* @return string|false
*/
function rnrd_repair_freemius_asset_url( $src ) {
if ( ! is_string( $src ) || '' === $src || ! defined( 'WP_FS__DIR' ) ) {
return $src;
}
$sdk = wp_normalize_path( WP_FS__DIR );
$pos = strpos( $src, $sdk );
if ( false === $pos ) {
return $src;
}
// Only this plugin's own SDK copy; another plugin's copy is its problem.
$root = untrailingslashit( wp_normalize_path( __DIR__ ) );
if ( 0 !== strpos( $sdk, $root . '/' ) ) {
return $src;
}
$rel = substr( $sdk, strlen( $root ) + 1 ) . substr( $src, $pos + strlen( $sdk ) );
return plugins_url( $rel, __FILE__ );
}
if ( is_admin() ) {
add_filter( 'style_loader_src', 'rnrd_repair_freemius_asset_url', 10 );
add_filter( 'script_loader_src', 'rnrd_repair_freemius_asset_url', 10 );
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Duplicate-install guard — prevent fatals when two copies are active.
// ─────────────────────────────────────────────────────────────────────────────
// WordPress does not dedupe plugin installs by slug. If a site ends up with
// two RankReady folders in /wp-content/plugins/ (e.g. one installed from a
// GitHub "Source code" zip named "RankReady-LLM-SEO-EEAT-AI-Optimization-1.5"
// and one from a release asset named "rankready"), WordPress will happily
// try to activate both. The second copy used to fatal the entire site because
// the autoloader captured RNRD_DIR from the first copy's location but the
// second copy's classes were in a different directory. This guard makes the
// second-loaded copy bail out cleanly with a dashboard notice instead.
//
// Regardless of folder name: the FIRST plugin file to define RNRD_VERSION wins.
// Every subsequent copy becomes a no-op and surfaces a warning to admins.
// ═════════════════════════════════════════════════════════════════════════════
if ( defined( 'RNRD_VERSION' ) ) {
add_action( 'admin_notices', function (): void {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
echo '<div class="notice notice-error"><p>';
echo '<strong>RankReady:</strong> ';
echo esc_html( sprintf(
/* translators: 1: active version, 2: second plugin folder name */
__( 'Another copy of RankReady is already active (version %1$s). The duplicate copy in "%2$s" has been disabled automatically to prevent conflicts. Delete the older folder from Plugins → Installed Plugins or via SFTP.', 'rankready-ai-llm-seo' ),
RNRD_VERSION,
basename( __DIR__ )
) );
echo '</p></div>';
} );
return; // Abort the rest of this file. No constants, no autoloader, no hooks.
}
// ── Constants (guarded to prevent conflicts) ─────────────────────────────────
if ( ! defined( 'RNRD_VERSION' ) ) {
define( 'RNRD_VERSION', '1.4.0' );
define( 'RNRD_FILE', __FILE__ );
define( 'RNRD_DIR', plugin_dir_path( __FILE__ ) );
define( 'RNRD_URL', plugin_dir_url( __FILE__ ) );
define( 'RNRD_BASENAME', plugin_basename( __FILE__ ) );
// Free build has NO monthly caps. Manual AI Summary + FAQ generation is
// unlimited. Auto-generate on publish and Bulk regeneration are not capped
// either: they are Pro features, shipped in the separate rankready-pro
// plugin, and Free shows them as locked rather than as unfinished work.
//
// No store URL constant either. The Free WP.org build relies entirely on
// WordPress.org's native update mechanism — no EDD updater, no Plugin
// Update Checker, no custom-update-server endpoint, no license activation.
// Pro (internal-only) lives in a separate branch with its own distribution.
// Option keys — LLM provider selection (multi-provider, since v1.1.1).
// `RNRD_OPT_KEY` and `RNRD_OPT_MODEL` below remain the OpenAI key/model for
// backwards compatibility — every existing install keeps working.
define( 'RNRD_OPT_LLM_PROVIDER', 'rnrd_llm_provider' ); // 'openai' | 'anthropic' | 'gemini' | 'deepseek'
// Anthropic (Claude).
define( 'RNRD_OPT_ANTHROPIC_KEY', 'rnrd_anthropic_api_key' );
define( 'RNRD_OPT_ANTHROPIC_MODEL', 'rnrd_anthropic_model' );
// Google Gemini.
define( 'RNRD_OPT_GEMINI_KEY', 'rnrd_gemini_api_key' );
define( 'RNRD_OPT_GEMINI_MODEL', 'rnrd_gemini_model' );
// DeepSeek.
define( 'RNRD_OPT_DEEPSEEK_KEY', 'rnrd_deepseek_api_key' );
define( 'RNRD_OPT_DEEPSEEK_MODEL', 'rnrd_deepseek_model' );
// "What's new" banner — last seen plugin version, per-user dismiss.
define( 'RNRD_OPT_INSTALLED_VERSION', 'rnrd_installed_version' );
// Option keys — AI Summary (OpenAI legacy keys, kept for back-compat).
define( 'RNRD_OPT_KEY', 'rnrd_openai_api_key' );
define( 'RNRD_OPT_MODEL', 'rnrd_openai_model' );
define( 'RNRD_OPT_POST_TYPES', 'rnrd_post_types' );
define( 'RNRD_OPT_LABEL', 'rnrd_default_label' );
define( 'RNRD_OPT_SHOW_LABEL', 'rnrd_default_show_label' );
define( 'RNRD_OPT_HEADING_TAG', 'rnrd_default_heading_tag' );
define( 'RNRD_OPT_AUTO_GENERATE', 'rnrd_auto_generate' );
define( 'RNRD_OPT_SUMMARY_ENABLE', 'rnrd_summary_enable' ); // Frontend: block, widget, shortcode, auto-display.
define( 'RNRD_OPT_AUTO_DISPLAY', 'rnrd_auto_display' ); // 'off' | 'before' | 'after' (legacy: 'on' + RNRD_OPT_DISPLAY_POSITION).
define( 'RNRD_OPT_DISPLAY_POSITION', 'rnrd_display_position' ); // Legacy; read fallback only.
define( 'RNRD_OPT_CUSTOM_PROMPT', 'rnrd_custom_prompt' );
define( 'RNRD_OPT_PRODUCT_CONTEXT', 'rnrd_product_context' );
// Option keys — LLMs.txt.
define( 'RNRD_OPT_LLMS_ENABLE', 'rnrd_llms_enable' );
define( 'RNRD_OPT_LLMS_SITE_NAME', 'rnrd_llms_site_name' );
define( 'RNRD_OPT_LLMS_SUMMARY', 'rnrd_llms_summary' );
define( 'RNRD_OPT_LLMS_ABOUT', 'rnrd_llms_about' );
define( 'RNRD_OPT_LLMS_POST_TYPES', 'rnrd_llms_post_types' );
define( 'RNRD_OPT_LLMS_MAX_POSTS', 'rnrd_llms_max_posts' );
define( 'RNRD_OPT_LLMS_CACHE_TTL', 'rnrd_llms_cache_ttl' );
define( 'RNRD_OPT_LLMS_FULL_ENABLE', 'rnrd_llms_full_enable' );
// Option keys — LLMs.txt taxonomy controls.
define( 'RNRD_OPT_LLMS_EXCLUDE_CATS', 'rnrd_llms_exclude_cats' );
define( 'RNRD_OPT_LLMS_EXCLUDE_TAGS', 'rnrd_llms_exclude_tags' );
define( 'RNRD_OPT_LLMS_SHOW_CATEGORIES', 'rnrd_llms_show_categories' );
define( 'RNRD_OPT_LLMS_USE_MD_URLS', 'rnrd_llms_use_md_urls' );
// Option keys — LLM Crawler robots.txt controls.
define( 'RNRD_OPT_ROBOTS_ENABLE', 'rnrd_robots_enable' );
define( 'RNRD_OPT_ROBOTS_CRAWLERS', 'rnrd_robots_crawlers' );
define( 'RNRD_OPT_ROBOTS_BLOCKED', 'rnrd_robots_blocked' ); // AI crawlers to hard-block (Disallow: /). Default empty = back-compat.
// v1.2.1 — UI transport for the per-crawler Allow/Default/Block radio. Map of
// user-agent => 'allow'|'block'|'default'. The two arrays above stay the
// source of truth for robots.txt output and are DERIVED from this on save,
// so every existing reader keeps working unchanged.
define( 'RNRD_OPT_ROBOTS_MODE', 'rnrd_robots_mode' );
// Option keys — Content Signals (contentsignals.org).
define( 'RNRD_OPT_CONTENT_SIGNALS_ENABLE', 'rnrd_content_signals_enable' );
define( 'RNRD_OPT_CONTENT_SIGNALS_AI_TRAIN', 'rnrd_content_signals_ai_train' );
define( 'RNRD_OPT_CONTENT_SIGNALS_SEARCH', 'rnrd_content_signals_search' );
define( 'RNRD_OPT_CONTENT_SIGNALS_AI_INPUT', 'rnrd_content_signals_ai_input' );
// Option keys — Markdown.
define( 'RNRD_OPT_MD_ENABLE', 'rnrd_md_enable' );
define( 'RNRD_OPT_MD_POST_TYPES', 'rnrd_md_post_types' );
define( 'RNRD_OPT_MD_INCLUDE_META', 'rnrd_md_include_meta' );
// Option keys — Open Knowledge Format (OKF) bundle (v1.1.5).
define( 'RNRD_OPT_OKF_ENABLE', 'rnrd_okf_enable' );
define( 'RNRD_OPT_OKF_POST_TYPES', 'rnrd_okf_post_types' );
// Option keys — Schema Automation.
define( 'RNRD_OPT_SCHEMA_ARTICLE', 'rnrd_schema_article' );
define( 'RNRD_OPT_SCHEMA_FAQ', 'rnrd_schema_faq' );
define( 'RNRD_OPT_SCHEMA_HOWTO', 'rnrd_schema_howto' );
define( 'RNRD_OPT_SCHEMA_ITEMLIST', 'rnrd_schema_itemlist' );
define( 'RNRD_OPT_SCHEMA_SPEAKABLE', 'rnrd_schema_speakable' );
define( 'RNRD_OPT_SCHEMA_BATCH_SIZE', 'rnrd_schema_batch_size' );
// Meta keys — Schema Automation (stored by WP-Cron scanner).
define( 'RNRD_META_SCHEMA_TYPE', '_rnrd_schema_type' ); // 'howto', 'itemlist', or ''
define( 'RNRD_META_SCHEMA_DATA', '_rnrd_schema_data' ); // Serialized schema array
define( 'RNRD_META_SCHEMA_HASH', '_rnrd_schema_hash' ); // md5(title+content) for change detection
// Cron — Schema scanner.
define( 'RNRD_SCHEMA_CRON_HOOK', 'rnrd_schema_scan' );
// Cron — Bulk operations (run even after browser close).
define( 'RNRD_CRON_BULK_STARTOVER', 'rnrd_cron_bulk_startover' );
define( 'RNRD_CRON_BULK_FAQ', 'rnrd_cron_bulk_faq' );
define( 'RNRD_CRON_BULK_SUMMARY', 'rnrd_cron_bulk_summary' );
// Bulk state — Schema scan.
define( 'RNRD_SCHEMA_QUEUE', 'rnrd_schema_queue' );
define( 'RNRD_SCHEMA_DONE', 'rnrd_schema_done' );
define( 'RNRD_SCHEMA_TOTAL', 'rnrd_schema_total' );
define( 'RNRD_SCHEMA_RUNNING', 'rnrd_schema_running' );
// Option keys — FAQ.
define( 'RNRD_OPT_DFS_LOGIN', 'rnrd_dfs_login' );
define( 'RNRD_OPT_DFS_PASSWORD', 'rnrd_dfs_password' );
define( 'RNRD_OPT_FAQ_POST_TYPES', 'rnrd_faq_post_types' );
define( 'RNRD_OPT_FAQ_COUNT', 'rnrd_faq_count' );
define( 'RNRD_OPT_FAQ_BRAND_TERMS', 'rnrd_faq_brand_terms' );
define( 'RNRD_OPT_FAQ_ENABLE', 'rnrd_faq_enable' ); // Frontend: block, widget, shortcode, auto-display.
define( 'RNRD_OPT_FAQ_AUTO_DISPLAY', 'rnrd_faq_auto_display' ); // 'off' | 'before' | 'after' (legacy: 'on' + RNRD_OPT_FAQ_POSITION).
define( 'RNRD_OPT_FAQ_POSITION', 'rnrd_faq_position' ); // Legacy; read fallback only.
define( 'RNRD_OPT_FAQ_HEADING_TAG', 'rnrd_faq_heading_tag' );
define( 'RNRD_OPT_FAQ_SHOW_REVIEWED','rnrd_faq_show_reviewed' );
define( 'RNRD_OPT_FAQ_AUTO_GENERATE','rnrd_faq_auto_generate' );
// Data retention.
define( 'RNRD_OPT_DELETE_ON_UNINSTALL', 'rnrd_delete_on_uninstall' );
// rc.11 — Hide "Generated from RankReady" credit line. SHIPPED, not a
// placeholder: RNRD_LLMs_Txt::should_hide_branding() reads this option and
// honours it whenever Pro is licensed. This comment used to say the option
// was ignored, which stopped being true two releases ago and made a built
// paid feature look unfinished to anyone reading the source.
define( 'RNRD_OPT_HIDE_BRANDING', 'rnrd_hide_branding' );
// Option keys — Author Box (EEAT).
define( 'RNRD_OPT_AUTHOR_ENABLE', 'rnrd_author_enable' ); // Frontend: block, widget, shortcode, auto-display.
define( 'RNRD_OPT_AUTHOR_AUTO_DISPLAY', 'rnrd_author_auto_display' ); // 'off' | 'before' | 'after' | 'both'
define( 'RNRD_OPT_AUTHOR_LAYOUT', 'rnrd_author_layout' ); // 'card' | 'compact' | 'inline'
define( 'RNRD_OPT_AUTHOR_HEADING', 'rnrd_author_heading' ); // Default heading text ("About the Author").
define( 'RNRD_OPT_AUTHOR_HEADING_TAG', 'rnrd_author_heading_tag' ); // Default heading tag.
define( 'RNRD_OPT_AUTHOR_SCHEMA_ENABLE', 'rnrd_author_schema_enable' ); // Emit Person schema (auto-skipped vs SEO plugins → merged instead).
define( 'RNRD_OPT_AUTHOR_EDITORIAL_URL', 'rnrd_author_editorial_url' ); // Site-wide publishingPrinciples URL.
define( 'RNRD_OPT_AUTHOR_FACTCHECK_URL', 'rnrd_author_factcheck_url' ); // "How we fact-check" URL (footer link).
define( 'RNRD_OPT_AUTHOR_POST_TYPES', 'rnrd_author_post_types' ); // Which post types auto-display the box on.
define( 'RNRD_OPT_AUTHOR_TRUST_ENABLE', 'rnrd_author_trust_enable' ); // Opt-in for the per-post Fact-Checked/Reviewed/Last-Reviewed panel.
// Per-post meta keys — Author Trust panel.
define( 'RNRD_META_AUTHOR_FACT_CHECKED_BY', '_rnrd_author_fact_checked_by' ); // user_id of fact-checker
define( 'RNRD_META_AUTHOR_REVIEWED_BY', '_rnrd_author_reviewed_by' ); // user_id of reviewer
define( 'RNRD_META_AUTHOR_LAST_REVIEWED', '_rnrd_author_last_reviewed' ); // YYYY-MM-DD string
define( 'RNRD_META_AUTHOR_DISABLE', '_rnrd_author_disable' ); // per-post opt-out
// Headless / Public API options.
define( 'RNRD_OPT_HEADLESS_ENABLE', 'rnrd_headless_enable' ); // Master toggle for public read-only API.
define( 'RNRD_OPT_HEADLESS_CORS_ORIGINS', 'rnrd_headless_cors_origins' ); // Comma-separated allowed frontend origins.
define( 'RNRD_OPT_HEADLESS_EXPOSE_META', 'rnrd_headless_expose_meta' ); // Register _rnrd_faq / _rnrd_summary in core REST.
define( 'RNRD_OPT_HEADLESS_CACHE_TTL', 'rnrd_headless_cache_ttl' ); // CDN cache max-age in seconds (s-maxage).
define( 'RNRD_OPT_HEADLESS_RATE_LIMIT', 'rnrd_headless_rate_limit' ); // Requests per minute per IP.
define( 'RNRD_OPT_HEADLESS_REVALIDATE_URL', 'rnrd_headless_revalidate_url' ); // Next.js/Nuxt webhook URL.
define( 'RNRD_OPT_HEADLESS_REVALIDATE_SEC', 'rnrd_headless_revalidate_secret' ); // Shared secret for webhook auth.
define( 'RNRD_OPT_HEADLESS_GRAPHQL', 'rnrd_headless_graphql' ); // Register WPGraphQL fields.
// ── v1.2.0 — Agent Ready options ──────────────────────────────────────────
// Brand Terms — single canonical input wired to llms.txt, robots.txt, FAQ prompt, and summary prompt.
define( 'RNRD_OPT_BRAND_TERMS', 'rnrd_brand_terms' );
// AI snippet preview controls.
define( 'RNRD_OPT_MAX_SNIPPET_DEFAULT', 'rnrd_max_snippet_default' ); // 'on'/'off' — default for new posts
define( 'RNRD_META_MAX_SNIPPET', '_rnrd_max_snippet' ); // per-post override: 'on'|'off'|'' (inherit)
// Per-post llms.txt exclusion.
define( 'RNRD_META_LLMS_EXCLUDE', '_rnrd_llms_exclude' ); // '1' = exclude from AI surfaces (llms.txt, Markdown, WebMCP, OKF)
// AI Insights tracking toggles.
define( 'RNRD_OPT_AI_TRAINING_ENABLE', 'rnrd_ai_training_enable' ); // 'on' | 'off' — master toggle for training-bot logging.
define( 'RNRD_OPT_AI_CITATION_ENABLE', 'rnrd_ai_citation_enable' ); // 'on' | 'off' — master toggle for citation-bot logging.
// AI Referral Traffic — daily counts per source, rolling 30 days.
define( 'RNRD_OPT_AI_REFERRAL_STATS', 'rnrd_ai_referral_stats' );
define( 'RNRD_OPT_AI_REFERRAL_ENABLE', 'rnrd_ai_referral_enable' ); // 'on' | 'off' — master toggle.
// WebMCP — master toggle for /.well-known/mcp.json + Abilities API registration.
define( 'RNRD_OPT_MCP_ENABLE', 'rnrd_mcp_enable' ); // 'on' | 'off' — opt-in, default off.
// WebMCP — per-resource exposure toggles (v1.2.0-beta.6).
// Sensible defaults: public content ON, PII/heavy/stack-reveal resources OFF.
define( 'RNRD_OPT_MCP_EXPOSE_POSTS', 'rnrd_mcp_expose_posts' ); // ON — core public content
define( 'RNRD_OPT_MCP_EXPOSE_PAGES', 'rnrd_mcp_expose_pages' ); // ON — static pages
define( 'RNRD_OPT_MCP_EXPOSE_AUTHORS', 'rnrd_mcp_expose_authors' ); // ON — EEAT signal
define( 'RNRD_OPT_MCP_EXPOSE_TAXONOMIES', 'rnrd_mcp_expose_taxonomies' ); // ON — discovery graph
define( 'RNRD_OPT_MCP_EXPOSE_SITEMAP', 'rnrd_mcp_expose_sitemap' ); // ON — cold-crawl seed
define( 'RNRD_OPT_MCP_EXPOSE_MENUS', 'rnrd_mcp_expose_menus' ); // ON — public anyway
define( 'RNRD_OPT_MCP_EXPOSE_LLMS_TXT', 'rnrd_mcp_expose_llms_txt' ); // ON — content already public
define( 'RNRD_OPT_MCP_EXPOSE_RR_AI', 'rnrd_mcp_expose_rr_ai' ); // ON — summaries / FAQs / brand
define( 'RNRD_OPT_MCP_EXPOSE_FRESHNESS', 'rnrd_mcp_expose_freshness' ); // ON — public surface
define( 'RNRD_OPT_MCP_EXPOSE_CPTS', 'rnrd_mcp_expose_cpts' ); // array — opt-in per CPT
define( 'RNRD_OPT_MCP_EXPOSE_COMMENTS', 'rnrd_mcp_expose_comments' ); // OFF — PII (author names/emails)
define( 'RNRD_OPT_MCP_EXPOSE_MEDIA', 'rnrd_mcp_expose_media' ); // OFF — heavy + non-attached uploads
define( 'RNRD_OPT_MCP_EXPOSE_USERS', 'rnrd_mcp_expose_users' ); // OFF — PII (full user list)
define( 'RNRD_OPT_MCP_EXPOSE_PLUGINS', 'rnrd_mcp_expose_plugins' ); // OFF — reveals stack / attack surface
define( 'RNRD_OPT_MCP_EXPOSE_THEMES', 'rnrd_mcp_expose_themes' ); // OFF — reveals stack
define( 'RNRD_OPT_MCP_EXPOSE_SETTINGS', 'rnrd_mcp_expose_settings' ); // OFF — may leak secrets
// Markdown layer sub-toggles (controlled inside the Markdown Endpoints card).
define( 'RNRD_OPT_MD_HOME_ENABLE', 'rnrd_md_home_enable' ); // 'on' | 'off' — homepage / posts-page markdown surfaces
define( 'RNRD_OPT_MD_HINT_DIV', 'rnrd_md_hint_div' ); // 'on' | 'off' — hidden AI-hint div in body
define( 'RNRD_OPT_MD_BOT_AUTO_SERVE', 'rnrd_md_bot_auto_serve' ); // 'on' | 'off' — UA-based forced markdown for AI bots
// v1.1.2 — Same-URL Accept-header content negotiation. DEFAULT OFF.
// When ON, a request to the canonical URL (/, /post-slug/) sending
// `Accept: text/markdown` (or a known AI-bot UA) receives markdown INLINE on
// that URL. This is UNSAFE behind any cache that ignores `Vary: Accept` —
// Cloudflare APO, Varnish, Fastly default, and most shared-host page caches
// all do. They cache the markdown body under the canonical URL and then serve
// it to every subsequent browser, blanking the page (live bug, nexterwp.com,
// 2026-06-02). Cloudflare's own docs confirm both that it ignores Vary values
// and that APO ignores origin Cache-Control at the edge, so NO origin header
// can make this safe. Markdown is therefore served ONLY at distinct `.md`
// URLs by default (different cache key = impossible to poison), discovered via
// the `Link: rel="alternate"; type="text/markdown"` header + llms.txt. The
// llms.txt spec, Vercel, Mintlify and GitBook all use distinct `.md` URLs as
// the cache-safe layer. Only turn this on if you control your cache key.
define( 'RNRD_OPT_MD_ACCEPT_NEGOTIATION', 'rnrd_md_accept_negotiation' ); // 'on' | 'off' — same-URL Accept negotiation (v1.2: default on; auto-guarded off on Cloudflare APO)
// Meta keys.
define( 'RNRD_META_SUMMARY', '_rnrd_summary' );
define( 'RNRD_META_HASH', '_rnrd_content_hash' );
define( 'RNRD_META_GENERATED', '_rnrd_last_generated' );
// Regeneration cooldown clock, kept apart from RNRD_META_GENERATED on purpose.
// GENERATED is the UI's "this post has a summary, made at X" and Delete must
// clear it. The cooldown must survive Delete, or Delete becomes a one-click way
// round the 60s throttle and straight back onto the provider's meter.
define( 'RNRD_META_COOLDOWN', '_rnrd_last_attempt' );
define( 'RNRD_META_DISABLE', '_rnrd_disable_summary' );
// Meta keys — FAQ.
define( 'RNRD_META_FAQ', '_rnrd_faq' );
define( 'RNRD_META_FAQ_HASH', '_rnrd_faq_hash' );
define( 'RNRD_META_FAQ_GENERATED', '_rnrd_faq_generated' );
define( 'RNRD_META_FAQ_COOLDOWN', '_rnrd_faq_last_attempt' ); // survives Delete, see RNRD_META_COOLDOWN.
define( 'RNRD_META_FAQ_DISABLE', '_rnrd_faq_disable' );
define( 'RNRD_META_FAQ_KEYWORD', '_rnrd_faq_keyword' );
define( 'RNRD_META_FAQ_LAST_FAILURE', '_rnrd_faq_last_failure' ); // v1.2.0 — circuit-breaker timestamp.
// Cron.
define( 'RNRD_CRON_HOOK', 'rnrd_async_generate' );
// Most posts one bulk run will queue. The queue is an option holding post
// IDs; the cap keeps it and the per-tick read bounded on very large sites.
// At the cap the option is about 158 KB and each claim rewrites it, measured
// at 11 ms in the lab (2026-09-28), which is small beside the provider call
// that follows every claim. The Pro bulk screens show the real matching
// count beside a capped run. The Free /startover-bulk/start route returns it
// as `available` for API callers; no Free screen calls that route.
define( 'RNRD_BULK_MAX_POSTS', 10000 );
// Bulk state — summary.
define( 'RNRD_BULK_QUEUE', 'rnrd_bulk_queue' );
define( 'RNRD_BULK_DONE', 'rnrd_bulk_done' );
define( 'RNRD_BULK_TOTAL', 'rnrd_bulk_total' );
define( 'RNRD_BULK_RUNNING', 'rnrd_bulk_running' );
// Bulk state — FAQ.
define( 'RNRD_FAQ_QUEUE', 'rnrd_faq_queue' );
define( 'RNRD_FAQ_DONE', 'rnrd_faq_done' );
define( 'RNRD_FAQ_TOTAL', 'rnrd_faq_total' );
define( 'RNRD_FAQ_RUNNING', 'rnrd_faq_running' );
// Bulk state — start over.
define( 'RNRD_SO_QUEUE', 'rnrd_so_queue' );
define( 'RNRD_SO_DONE', 'rnrd_so_done' );
define( 'RNRD_SO_TOTAL', 'rnrd_so_total' );
define( 'RNRD_SO_RUNNING', 'rnrd_so_running' );
// Bulk state — author.
define( 'RNRD_BAC_QUEUE', 'rnrd_bac_queue' );
define( 'RNRD_BAC_TOTAL', 'rnrd_bac_total' );
define( 'RNRD_BAC_DONE', 'rnrd_bac_done' );
define( 'RNRD_BAC_RUNNING', 'rnrd_bac_running' );
define( 'RNRD_BAC_TO', 'rnrd_bac_to_author' );
// Transient keys.
define( 'RNRD_LLMS_CACHE_KEY', 'rnrd_llms_txt_cache' );
define( 'RNRD_LLMS_FULL_CACHE_KEY', 'rnrd_llms_full_txt_cache' );
}
// ── rnrd_is_pro() — Pro extension point ─────────────────────────────────────
// The Free build always returns false by default. The companion Pro addon
// (or any third party with permission) opts in by attaching to the
// `rnrd_is_pro` filter:
//
// add_filter( 'rnrd_is_pro', '__return_true' );
//
// This filter pattern means Pro never needs to win a function_exists race,
// never needs a mu-plugin trick, never needs to load before Free in the
// plugin order — it just hooks in like any other WordPress filter. Free
// stays the single source of truth for the function itself.
if ( ! function_exists( 'rnrd_is_pro' ) ) {
function rnrd_is_pro(): bool {
return (bool) apply_filters( 'rnrd_is_pro', false );
}
/**
* Where an upgrade prompt sends the user.
*
* Prefers the Freemius pricing screen inside wp-admin, because a license
* bought there activates itself and the buyer never handles a key. Falls
* back to the public pricing page when the SDK is unavailable, which is the
* case on a site that skipped the opt-in.
*
* @param string $context Short slug naming the surface the click came from,
* so the upgrade page can report what people click.
*/
/**
* Where "Free vs Pro" points. A comparison is a different intent from a
* price: someone clicking it is not ready to buy, they are checking whether
* the paid thing is even relevant to them. Sending both to the same page
* loses that reader.
*/
function rnrd_compare_url( string $context = '' ): string {
$url = 'https://hostmy.blog/plugins/rankready/#free-vs-pro';
return $context ? add_query_arg( 'from', rawurlencode( $context ), $url ) : $url;
}
/**
* The inline gate shown next to a control a free site cannot use.
*
* Deliberately quiet: one sentence saying why THIS feature is paid, one
* action, one comparison link. Modelled on Relume's inline gate rather than
* on a marketing banner, because this sits beside a checkbox in a settings
* screen and anything louder reads as an advert.
*
* @param string $why One sentence. Specific to the feature, never generic.
* @param string $context Slug for upgrade attribution.
*/
/**
* A field-level Pro note. One line, inline, sitting where a `.description`
* would sit.
*
* This used to render a bordered full-width bar with two sentences and a
* solid button, attached to a single checkbox row. That is the treatment
* competitors reserve for a whole locked feature area, not for one field,
* and next to a pair of checkboxes it read as an advert wedged into a form.
*
* What the two biggest plugins in this category actually ship, read from
* their current WordPress.org builds rather than from memory:
*
* Yoast SEO `.yoast-badge` + `.yoast-premium-badge`: a 10px inline
* pill, background #fff3cd on #674e00 text, sitting beside
* the label. No prose, no button. Fields carry a
* `has_premium_badge` boolean, so the flag is per field.
* Rank Math `.rank-math-pro-cta` is a box, and only ever wraps a
* whole feature area. Its inner button is even
* `pointer-events: none`, so it is decoration, not a CTA.
*
* So: a pill plus one clause for a field, and the panel (rnrd_pro_panel)
* stays for a whole feature. The link is kept, unlike Yoast, because a flag
* with no way to find out what it means is its own small frustration.
*
* @param string $why One short clause. Not a sentence, not two.
* @param string $context Attribution slug for the upgrade URL.
*/
function rnrd_gate( string $why, string $context = 'gate' ): void {
?>
<p class="rnrd-gate">
<span class="rnrd-gate__text"><?php echo wp_kses_post( $why ); ?></span>
<?php
/*
* The tag is LAST in the markup and pinned right in CSS, and it is
* itself the link. It used to lead the sentence with a separate
* "Upgrade . Free vs Pro" pair trailing it, which put three
* clickable things on one annotation line and left the marker at a
* different x-position on every row. One target, one column.
*/
?>
<a class="rnrd-gate__tag" href="<?php echo esc_url( rnrd_upgrade_url( $context ) ); ?>"><?php esc_html_e( 'Pro', 'rankready-ai-llm-seo' ); ?></a>
</p>
<?php
}
/**
* Documentation base. One place, so a domain move is one edit.
*
* Docs live at /docs/{product}/{article}/ on hostmy.blog and are a separate
* surface from the sales page: /plugins/rankready/ converts, /docs/rankready/
* educates. See the docs architecture note before changing this shape; the
* section is a taxonomy and is deliberately NOT part of the path.
*/
function rnrd_docs_base(): string {
return 'https://hostmy.blog/docs/rankready/';
}
/**
* Every admin screen mapped to the doc that answers it.
*
* Keys are `tab` or `tab/sub`, matching the admin router exactly. Every slug
* in this map was checked against the live site and returned 200; a link to a
* doc that does not exist is worse than no link, because it teaches people
* the Help link is broken and they stop trying it.
*
* @return array<string,string>
*/
function rnrd_doc_map(): array {
return array(
'dashboard' => 'what-rankready-does',
'crawlers' => 'supported-ai-crawlers',
'crawlers/brand' => 'brand-terms-setup',
'crawlers/llms' => 'how-to-add-llms-txt-wordpress',
'crawlers/markdown' => 'wordpress-markdown-for-ai',
'crawlers/okf' => 'what-is-open-knowledge-format',
'crawlers/robots' => 'content-signals-robots-txt',
'crawlers/webmcp' => 'how-to-add-webmcp-wordpress',
'content' => 'make-post-ai-ready',
'content/summary' => 'generate-ai-summaries-wordpress',
'content/faq' => 'how-to-add-faq-schema-wordpress',
'content/author' => 'how-to-add-eeat-author-box',
'content/schema' => 'add-article-speakable-schema',
'insights' => 'check-rankready-is-working',
'insights/bot-activity' => 'see-which-ai-crawlers-visit',
'insights/citation' => 'what-citation-candidate-means',
'insights/freshness' => 'check-rankready-is-working',
'insights/referral' => 'track-chatgpt-perplexity-traffic',
'settings' => 'settings-reference',
'settings/api-keys' => 'do-you-need-api-key',
'settings/cloudflare' => 'rankready-caching-cdn-fix',
'settings/advanced' => 'hooks-and-filters',
);
}
/**
* URL for one doc, or the docs hub when no slug is given.
*
* @param string $slug Article slug, or '' for the RankReady docs hub.
* @param string $context Attribution, so it is visible which screen sends
* readers to which doc.
*/
function rnrd_doc_url( string $slug = '', string $context = '' ): string {
$url = rnrd_docs_base() . ( $slug ? rawurlencode( $slug ) . '/' : '' );
/*
* UTM on our own docs, so it is possible to see which admin screen
* actually sends people to the documentation and which articles nobody
* ever opens. That is a product question the plugin cannot answer any
* other way: nothing is tracked locally and nothing is phoned home.
*
* These are ordinary query parameters on a first-party URL, which is
* what every WordPress plugin's help link carries. No identifier of the
* site or the user is included, and `context` is a screen slug we wrote,
* never anything the user typed.
*/
$args = array(
'utm_source' => 'rankready-plugin',
'utm_medium' => 'admin-help',
'utm_campaign' => 'docs',
);
if ( '' !== $context ) {
$args['utm_content'] = rawurlencode( $context );
}
return add_query_arg( $args, $url );
}
/**
* The doc slug for the screen being viewed, falling back up the hierarchy:
* `tab/sub`, then `tab`, then the hub.
*/
function rnrd_doc_for_screen(): string {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only screen check; nothing is written.
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard';
$sub = isset( $_GET['sub'] ) ? sanitize_key( wp_unslash( $_GET['sub'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$map = rnrd_doc_map();
if ( $sub && isset( $map[ $tab . '/' . $sub ] ) ) {
return $map[ $tab . '/' . $sub ];
}
return $map[ $tab ] ?? '';
}
/**
* A documentation link that sits beside the thing it explains.
*
* Real products do not put a "read the docs for this screen" bar at the top
* and leave you to work out which paragraph applies. They put "Learn more"
* next to the option, at the moment the question occurs. This renders that:
* a small inline link, placed at the setting, naming what it will explain.
*
* @param string $slug Article slug. Verified against the live site.
* @param string $label Link text. Defaults to "Learn more", but "See how it
* works" reads better on a feature than on a checkbox,
* so callers pass what fits.
*/
function rnrd_doc_link( string $slug, string $label = '' ): string {
if ( '' === $slug ) {
return '';
}
/*
* ONE help affordance, worded the same way on every screen.
*
* These links used to carry 32 different labels across 35 call sites:
* "What this does", "How to start", "Which crawlers", "Cache setup",
* "About llms.txt", "Common errors". Each one read fine on its own and
* the set read like six different products. A reader cannot learn where
* help lives if help is dressed differently every time they meet it.
*
* So the visible word is always "Help" and it always looks the same.
* The specific promise is not thrown away: it becomes the link's
* accessible name and its tooltip, so a screen reader announces
* "Help: How summaries work" and a hover shows the same. Callers keep
* passing their specific label; it just stops being the visible text.
*/
$topic = '' !== $label ? $label : __( 'Read the documentation', 'rankready-ai-llm-seo' );
$visible = __( 'Help', 'rankready-ai-llm-seo' );
return sprintf(
'<a class="rnrd-doc-link" href="%1$s" target="_blank" rel="noopener noreferrer" title="%2$s" aria-label="%3$s">%4$s<span class="dashicons dashicons-external rnrd-extlink" aria-hidden="true"></span></a>',
esc_url( rnrd_doc_url( $slug, 'inline-' . $slug ) ),
esc_attr( $topic ),
esc_attr(
sprintf(
/* translators: %s: what the linked article explains, e.g. "How summaries work" */
__( 'Help: %s (opens in a new tab)', 'rankready-ai-llm-seo' ),
$topic
)
),
esc_html( $visible )
);
}
/** Echoing wrapper, for use inside templates. */
function rnrd_doc( string $slug, string $label = '' ): void {
/*
* wp_kses_post() drops `aria-label` and `title`, which is exactly the
* information this link now depends on, so the allow-list is explicit.
* Learned the hard way earlier in this release: wp_kses_post() silently
* stripped an <input> and left a gated row with no checkbox at all.
*/
echo wp_kses(
rnrd_doc_link( $slug, $label ),
array(
'a' => array(
'class' => array(),
'href' => array(),
'target' => array(),
'rel' => array(),
'title' => array(),
'aria-label' => array(),
),
'span' => array(
'class' => array(),
'aria-hidden' => array(),
),
)
);
}
function rnrd_upgrade_url( string $context = '' ): string {
if ( function_exists( 'admin_url' ) && is_admin() ) {
$url = admin_url( 'admin.php?page=rankready-pro-upgrade' );
return $context ? add_query_arg( 'rnrd_from', rawurlencode( $context ), $url ) : $url;
}
$fallback = 'https://hostmy.blog/plugins/rankready/#pricing';
return $context ? add_query_arg( 'from', rawurlencode( $context ), $fallback ) : $fallback;
}
/**
* The priority RankReady registers its `template_redirect` handlers at.
*
* FREE-99 / issue #41 — every RankReady handler on `template_redirect`
* (llms.txt, llms-full.txt, OKF, MCP manifest, the .md router, the Accept:
* text/markdown negotiator) used to sit at a small hardcoded priority (0-2)
* chosen only to beat WordPress core and a couple of named page builders.
* A stock site running Bricks, Oxygen, Cwicly or any other builder/mu-plugin
* that also hooks `template_redirect` early can still register before us,
* intercept the request, and serve HTML where an AI crawler expected plain
* text or markdown — and the only fix available to the user was asking the
* OTHER plugin's author to change their priority, which is not something
* RankReady can promise or control.
*
* Defaulting to a very low (very early) number instead — filterable, so a
* site with a genuinely earlier requirement can push it lower still —
* removes the race in the overwhelming majority of installs by construction,
* rather than by asking every other plugin author to cooperate.
*
* @param int $offset Added to the base priority. RankReady's five
* `template_redirect` registrations each pass a small
* distinct offset (0-2) so their relative firing order
* among themselves is preserved (e.g. OKF before the
* markdown router, which is before the Accept-header
* negotiator).
* @return int
*/
function rnrd_template_redirect_priority( int $offset = 0 ): int {
return (int) apply_filters( 'rnrd_template_redirect_priority', -9999 ) + $offset;
}
}
/**
* Resolve Auto-display to off|before|after.
* Legacy Summary/FAQ stored 'on' plus a separate position option.
* 'both' is Author Box only; maps to the feature's default position.
*/
/*
* These two are wrapped in function_exists() and the rest of this file's
* helpers are not, which looks inconsistent until you know why.
*
* PHP binds an UNCONDITIONAL top-level function at COMPILE time, before a
* single line of the file executes. The duplicate-install guard higher up
* returns early, and that early return cannot prevent a compile-time binding.
* Every other rnrd_* helper here lives inside a conditional block, so it is
* bound at runtime and the guard does protect it. These two sat at column
* zero, so they were the only two the guard could not cover, and they were
* exactly the two that fataled a site running two RankReady folders:
*
* PHP Fatal error: Cannot redeclare rnrd_auto_display_mode()
*
* That is worse than an ordinary bug, because the guard's own admin notice
* promises the duplicate "has been disabled automatically to prevent
* conflicts" while the site is already white-screened. Reproduced in the lab
* by activating the free and premium builds together, then fixed and
* re-verified the same way.
*/
if ( ! function_exists( 'rnrd_auto_display_mode' ) ) {
function rnrd_auto_display_mode( string $option, string $legacy_position_option, string $legacy_position_default ): string {
$v = (string) get_option( $option, 'off' );
if ( in_array( $v, array( 'off', 'before', 'after' ), true ) ) {
return $v;
}
if ( 'both' === $v ) {
return $legacy_position_default;
}
if ( 'on' === $v ) {
$pos = (string) get_option( $legacy_position_option, $legacy_position_default );
return 'before' === $pos ? 'before' : 'after';
}
return 'off';
}
/**
* One-shot: rewrite legacy 'on' Auto-display rows to before/after.
*/
}
if ( ! function_exists( 'rnrd_maybe_merge_auto_display_options' ) ) {
function rnrd_maybe_merge_auto_display_options(): void {
if ( get_option( 'rnrd_auto_display_merged' ) ) {
return;
}
$summary = (string) get_option( RNRD_OPT_AUTO_DISPLAY, 'off' );
if ( 'on' === $summary ) {
$pos = (string) get_option( RNRD_OPT_DISPLAY_POSITION, 'before' );
update_option( RNRD_OPT_AUTO_DISPLAY, 'before' === $pos ? 'before' : 'after', false );
}
$faq = (string) get_option( RNRD_OPT_FAQ_AUTO_DISPLAY, 'off' );
if ( 'on' === $faq ) {
$pos = (string) get_option( RNRD_OPT_FAQ_POSITION, 'after' );
update_option( RNRD_OPT_FAQ_AUTO_DISPLAY, 'before' === $pos ? 'before' : 'after', false );
}
update_option( 'rnrd_auto_display_merged', '1', false );
}
}
// ── Autoloader ────────────────────────────────────────────────────────────────
add_action( 'admin_init', function (): void {
if ( class_exists( 'RNRD_Notices' ) ) {
RNRD_Notices::init();
}
} );
// Inside a hook, not at file scope: the autoloader that resolves RNRD_Pricing is
// registered further down this file, so a class_exists() at load time would run
// before it exists and silently skip the whole checkout layer.
add_action( 'init', function (): void {
if ( class_exists( 'RNRD_Pricing' ) ) {
RNRD_Pricing::init();
}
} );
/**
* Keep the RankReady menu open and highlighted on the Upgrade Pro screen.
*
* That screen is registered with a null parent so it does not add a second
* visible row, and the cost of that is WordPress not knowing which menu it
* belongs to: landing there collapsed the RankReady menu and highlighted
* nothing, so it read as having left the plugin entirely.
*/
add_filter( 'parent_file', function ( $parent ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check.
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
return ( 'rankready-pro-upgrade' === $page ) ? 'rankready-ai-llm-seo' : $parent;
} );
add_filter( 'submenu_file', function ( $file ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check.
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
return ( 'rankready-pro-upgrade' === $page ) ? 'admin.php?page=rankready-pro-upgrade' : $file;
} );
/**
* Our own Upgrade Pro screen, in place of the Freemius one.
*
* Freemius's stock pricing page lists paid plans only, so a reader cannot see
* what they already have next to what they would be buying. This registers a
* hidden submenu at the same slug Freemius uses, then points the sidebar item at
* it, so "Upgrade Pro" opens a screen that shows Free beside Pro and checks out
* without leaving WordPress.
*
* Registered with a null parent so it never adds a second visible row.
*/
add_action( 'admin_menu', function (): void {
$hook = add_submenu_page(
'',
__( 'Upgrade Pro', 'rankready-ai-llm-seo' ),
__( 'Upgrade Pro', 'rankready-ai-llm-seo' ),