PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 All 106 releases
← All changes | src/UI/class-settings-page.php +479 -52 3.16.0 → 3.24.2 View file →
@@ -9,15 +9,15 @@
9 9 declare(strict_types=1);
10 10
11 11 namespace Parsely\UI;
12 12
13 -use Parsely\Content_Helper\Excerpt_Generator;
13 +use Parsely\Content_Helper\Excerpt_Suggestions;
14 +use Parsely\Content_Helper\Suggestion_Defaults;
14 15 use Parsely\Parsely;
15 16 use Parsely\Permissions;
17 +use Parsely\Utils\Utils;
16 18 use Parsely\Validator;
17 19
18 -use function Parsely\Utils\get_asset_info;
19 -
20 20 use const Parsely\PARSELY_FILE;
21 21
22 22 /**
23 23 * Renders the wp-admin Parse.ly plugin settings page.
@@ -23,10 +23,8 @@
23 23 * Renders the wp-admin Parse.ly plugin settings page.
24 24 *
25 25 * @since 3.0.0
26 26 *
27 - * @phpstan-import-type Parsely_Options from Parsely
28 - *
29 27 * @phpstan-type Setting_Arguments array{
30 28 * add_fieldset?: bool,
31 29 * legend?: string,
32 30 * option_key: string,
@@ -64,8 +62,9 @@
64 62 * use_top_level_cats?:bool|string,
65 63 * custom_taxonomy_section?: string,
66 64 * cats_as_tags?: bool|string,
67 65 * content_helper: Parsely_Settings_Options_Content_Helper,
66 + * headline_testing?: Parsely_Options_Headline_Testing,
68 67 * lowercase_tags?: bool,
69 68 * force_https_canonicals?: bool,
70 69 * disable_autotrack?: bool|string,
71 70 * }
@@ -74,16 +73,21 @@
74 73 * ai_features_enabled?: bool,
75 74 * smart_linking?: Parsely_Settings_Options_Content_Helper_Feature,
76 75 * title_suggestions?: Parsely_Settings_Options_Content_Helper_Feature,
77 76 * excerpt_suggestions?: Parsely_Settings_Options_Content_Helper_Feature,
77 + * traffic_boost?: Parsely_Settings_Options_Content_Helper_Feature,
78 78 * }
79 79 *
80 80 * @phpstan-type Parsely_Settings_Options_Content_Helper_Feature array{
81 81 * enabled?: bool,
82 - * allowed_user_roles?: array<string, string>|array<string, bool>
82 + * allowed_user_roles?: array<string, string>|array<string, bool>,
83 + * default_length?: mixed,
84 + * default_tone?: mixed,
85 + * default_persona?: mixed
83 86 * }
84 87 *
85 88 * @phpstan-import-type Parsely_Options from Parsely
89 + * @phpstan-import-type Parsely_Options_Headline_Testing from Parsely
86 90 */
87 91 final class Settings_Page {
88 92 /**
89 93 * Instance of Parsely class.
@@ -110,10 +114,24 @@
110 114 */
111 115 private $managed_options_badge = array();
112 116
113 117 /**
114 - * The Content Helper features that can be configured in the settings page.
118 + * The Content Intelligence features that expose site-wide generation
119 + * defaults in the settings page.
115 120 *
121 + * @since 3.24.0
122 + *
123 + * @var string[]
124 + */
125 + private const FEATURES_WITH_DEFAULTS = array(
126 + 'title_suggestions',
127 + 'excerpt_suggestions',
128 + );
129 +
130 + /**
131 + * The Content Intelligence features that can be configured in the settings
132 + * page.
133 + *
116 134 * @since 3.16.0
117 135 *
118 136 * @var string[]
119 137 */
@@ -120,8 +138,9 @@
120 138 private $configurable_pch_features = array(
121 139 'smart_linking',
122 140 'title_suggestions',
123 141 'excerpt_suggestions',
142 + 'traffic_boost',
124 143 );
125 144
126 145 /**
127 146 * Constructor.
@@ -160,9 +179,9 @@
160 179 *
161 180 * @param string|null $hook_suffix The current page being loaded.
162 181 */
163 182 public function enqueue_settings_assets( ?string $hook_suffix ): void {
164 - if ( ! is_string( $hook_suffix ) || 'settings_page_parsely' !== $hook_suffix ) {
183 + if ( ! is_string( $hook_suffix ) || $this->hook_suffix !== $hook_suffix ) {
165 184 return;
166 185 }
167 186
168 187 add_filter( 'media_library_months_with_files', '__return_empty_array' );
@@ -167,10 +186,10 @@
167 186
168 187 add_filter( 'media_library_months_with_files', '__return_empty_array' );
169 188 wp_enqueue_media();
170 189
171 - $admin_settings_asset = get_asset_info( 'build/admin-settings.asset.php' );
172 - $built_assets_url = plugin_dir_url( PARSELY_FILE ) . '/build/';
190 + $admin_settings_asset = Utils::get_asset_info( 'build/admin-settings.asset.php' );
191 + $built_assets_url = plugin_dir_url( PARSELY_FILE ) . 'build/';
173 192
174 193 wp_enqueue_script(
175 194 'parsely-admin-settings',
176 195 $built_assets_url . 'admin-settings.js',
@@ -181,9 +200,9 @@
181 200
182 201 wp_enqueue_style(
183 202 'parsely-admin-settings',
184 203 $built_assets_url . 'admin-settings.css',
185 - $admin_settings_asset['dependencies'],
204 + array(),
186 205 $admin_settings_asset['version']
187 206 );
188 207 }
189 208
@@ -190,11 +209,12 @@
190 209 /**
191 210 * Adds the Parse.ly settings page in WordPress settings menu.
192 211 */
193 212 public function add_settings_sub_menu(): void {
194 - $suffix = add_options_page(
213 + $suffix = add_submenu_page(
214 + 'parsely-dashboard-page',
195 215 __( 'Parse.ly Settings', 'wp-parsely' ),
196 - __( 'Parse.ly', 'wp-parsely' ),
216 + __( 'Settings', 'wp-parsely' ),
197 217 Parsely::CAPABILITY, // phpcs:ignore WordPress.WP.Capabilities.Undetermined
198 218 Parsely::MENU_SLUG,
199 219 array( $this, 'display_settings' )
200 220 );
@@ -228,9 +248,9 @@
228 248 );
229 249 }
230 250
231 251 /**
232 - * Displays the Parse.ly settings screen (options-general.php?page=[SLUG]).
252 + * Displays the Parse.ly settings screen (admin.php?page=[SLUG]).
233 253 */
234 254 public function display_settings(): void {
235 255 // phpcs:ignore WordPress.WP.Capabilities.Undetermined
236 256 if ( ! current_user_can( Parsely::CAPABILITY ) ) {
@@ -236,9 +256,9 @@
236 256 if ( ! current_user_can( Parsely::CAPABILITY ) ) {
237 257 wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-parsely' ) );
238 258 }
239 259
240 - include_once plugin_dir_path( PARSELY_FILE ) . 'views/parsely-settings.php';
260 + include_once plugin_dir_path( PARSELY_FILE ) . 'src/UI/settings-page.php';
241 261 }
242 262
243 263 /**
244 264 * Initializes the settings for Parse.ly.
@@ -260,8 +280,9 @@
260 280 );
261 281
262 282 $this->initialize_basic_section();
263 283 $this->initialize_content_helper_section();
284 + $this->initialize_headline_testing_section();
264 285 $this->initialize_recrawl_section();
265 286 $this->initialize_advanced_section();
266 287 }
267 288
@@ -428,18 +449,18 @@
428 449 }
429 450 }
430 451
431 452 /**
432 - * Registers the Content Helper section and its settings.
453 + * Registers section and settings for Content Intelligence section.
433 454 *
434 455 * @since 3.16.0
435 456 */
436 457 private function initialize_content_helper_section(): void {
437 - $section_key = 'content-helper-section';
458 + $section_key = 'content-intelligence-section';
438 459
439 460 add_settings_section(
440 461 $section_key,
441 - __( 'Content Helper', 'wp-parsely' ),
462 + __( 'Content Intelligence', 'wp-parsely' ),
442 463 '__return_null',
443 464 Parsely::MENU_SLUG
444 465 );
445 466
@@ -498,9 +519,9 @@
498 519 $field_args = array(
499 520 'option_key' => $field_id,
500 521 'label_for' => $field_id,
501 522 'legend' => __( 'Excerpt Suggestions', 'wp-parsely' ),
502 - 'filter' => Excerpt_Generator::get_feature_filter_name(),
523 + 'filter' => Excerpt_Suggestions::get_feature_filter_name(),
503 524 );
504 525 add_settings_field(
505 526 $field_id,
506 527 __( 'Excerpt Suggestions', 'wp-parsely' ),
@@ -508,11 +529,159 @@
508 529 Parsely::MENU_SLUG,
509 530 $section_key,
510 531 $field_args
511 532 );
533 +
534 + // Traffic Boost.
535 + $field_id = 'content_helper[traffic_boost]';
536 + $field_args = array(
537 + 'option_key' => $field_id,
538 + 'label_for' => $field_id,
539 + 'legend' => __( 'Engagement Boost (beta)', 'wp-parsely' ),
540 + );
541 + add_settings_field(
542 + $field_id,
543 + __( 'Engagement Boost (beta)', 'wp-parsely' ),
544 + array( $this, 'print_content_helper_ai_feature_section' ),
545 + Parsely::MENU_SLUG,
546 + $section_key,
547 + $field_args
548 + );
512 549 }
513 550
514 551 /**
552 + * Registers section and settings for Headline Testing section.
553 + *
554 + * @since 3.21.0
555 + */
556 + private function initialize_headline_testing_section(): void {
557 + $section_key = 'headline-testing-section';
558 +
559 + add_settings_section(
560 + $section_key,
561 + __( 'Headline Testing', 'wp-parsely' ),
562 + function (): void {
563 + echo '<p>' . esc_html__( 'Configure Parse.ly Headline Testing to automatically test different headline variations and optimize for engagement.', 'wp-parsely' ) . '</p>';
564 + echo '<p><a href="https://docs.parse.ly/dashboard/optimization-menu/headline-testing-tab/" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Learn more about Headline Testing', 'wp-parsely' ) . '</a></p>';
565 + },
566 + Parsely::MENU_SLUG
567 + );
568 +
569 + // Enable Headline Testing.
570 + $field_id = 'headline_testing[enabled]';
571 + $field_args = array(
572 + 'option_key' => $field_id,
573 + 'label_for' => $field_id,
574 + 'yes_text' => __( 'Enabled', 'wp-parsely' ),
575 + 'add_fieldset' => true,
576 + 'legend' => __( 'Headline Testing', 'wp-parsely' ),
577 + 'help_text' => __( 'Enable Parse.ly Headline Testing to automatically test different headline variations.', 'wp-parsely' ),
578 + );
579 + add_settings_field(
580 + $field_id,
581 + __( 'Headline Testing', 'wp-parsely' ),
582 + array( $this, 'print_checkbox_tag' ),
583 + Parsely::MENU_SLUG,
584 + $section_key,
585 + $field_args
586 + );
587 +
588 + // Installation Method.
589 + $field_id = 'headline_testing[installation_method]';
590 + $field_args = array(
591 + 'option_key' => $field_id,
592 + 'label_for' => $field_id,
593 + 'help_text' => __( 'Choose how you want to install the Headline Testing script. One-line snippet is recommended for most sites.', 'wp-parsely' ),
594 + 'radio_options' => array(
595 + 'one_line' => __( 'One-line Snippet (Recommended)', 'wp-parsely' ),
596 + 'advanced' => __( 'Advanced Installation', 'wp-parsely' ),
597 + ),
598 + );
599 + add_settings_field(
600 + $field_id,
601 + __( 'Installation Method', 'wp-parsely' ),
602 + array( $this, 'print_radio_tags' ),
603 + Parsely::MENU_SLUG,
604 + $section_key,
605 + $field_args
606 + );
607 +
608 + // Enable Flicker Control (Advanced only).
609 + $field_id = 'headline_testing[enable_flicker_control]';
610 + $field_args = array(
611 + 'option_key' => $field_id,
612 + 'label_for' => $field_id,
613 + 'yes_text' => __( 'Enabled', 'wp-parsely' ),
614 + 'help_text' => __( 'Hide page body for up to 500ms to prevent flickering when headlines are replaced. Only available with Advanced installation.', 'wp-parsely' ),
615 + );
616 + add_settings_field(
617 + $field_id,
618 + __( 'Enable Flicker Control', 'wp-parsely' ),
619 + array( $this, 'print_checkbox_tag' ),
620 + Parsely::MENU_SLUG,
621 + $section_key,
622 + $field_args
623 + );
624 +
625 + // Enable Live Updates.
626 + $field_id = 'headline_testing[enable_live_updates]';
627 + $field_args = array(
628 + 'option_key' => $field_id,
629 + 'label_for' => $field_id,
630 + 'yes_text' => __( 'Enabled', 'wp-parsely' ),
631 + 'help_text' => __( 'Watch for new content and automatically update headlines for newly added anchors.', 'wp-parsely' ),
632 + );
633 + add_settings_field(
634 + $field_id,
635 + __( 'Enable Live Updates', 'wp-parsely' ),
636 + array( $this, 'print_checkbox_tag' ),
637 + Parsely::MENU_SLUG,
638 + $section_key,
639 + $field_args
640 + );
641 +
642 + // Live Update Timeout.
643 + $field_id = 'headline_testing[live_update_timeout]';
644 + $field_args = array(
645 + 'option_key' => $field_id,
646 + 'label_for' => $field_id,
647 + 'help_text' => __( 'How long to watch for new content (in milliseconds). Default: 30000 (30 seconds).', 'wp-parsely' ),
648 + 'optional_args' => array(
649 + 'type' => 'number',
650 + 'placeholder' => '30000',
651 + 'min' => '1000',
652 + 'max' => '60000',
653 + 'step' => '1000',
654 + ),
655 + );
656 + add_settings_field(
657 + $field_id,
658 + __( 'Live Update Timeout (ms)', 'wp-parsely' ),
659 + array( $this, 'print_text_tag' ),
660 + Parsely::MENU_SLUG,
661 + $section_key,
662 + $field_args
663 + );
664 +
665 + // Allow After Content Load.
666 + $field_id = 'headline_testing[allow_after_content_load]';
667 + $field_args = array(
668 + 'option_key' => $field_id,
669 + 'label_for' => $field_id,
670 + 'yes_text' => __( 'Enabled', 'wp-parsely' ),
671 + 'help_text' => __( 'Allow headline swapping even after the main content has loaded. May cause flickering. Highly recommended if you are loading your script asynchronously.', 'wp-parsely' ),
672 + );
673 + add_settings_field(
674 + $field_id,
675 + __( 'Allow After Content Load', 'wp-parsely' ),
676 + array( $this, 'print_checkbox_tag' ),
677 + Parsely::MENU_SLUG,
678 + $section_key,
679 + $field_args
680 + );
681 + }
682 +
683 + /**
515 684 * Registers section and settings for Recrawl section.
516 685 *
517 686 * @since 3.2.0
518 687 */
@@ -545,9 +714,8 @@
545 714 array(
546 715 'title' => __( 'Track Post Types as', 'wp-parsely' ),
547 716 'option_key' => $field_id,
548 717 'help_text' => $field_help,
549 - 'filter' => 'wp_parsely_trackable_statuses',
550 718 )
551 719 );
552 720
553 721 // Use full metadata in non-posts.
@@ -819,27 +987,27 @@
819 987 *
820 988 * @param Setting_Arguments $args The arguments for text tag.
821 989 */
822 990 public function print_text_tag( $args ): void {
823 - $options = $this->parsely->get_options();
824 - $name = $args['option_key'];
825 - /**
826 - * Variable.
827 - *
828 - * @var string
829 - */
830 - $value = $options[ $name ] ?? '';
991 + $options = $this->parsely->get_options();
992 + $name = $args['option_key'];
993 + $raw_value = $this->get_option_value( $name, $options );
994 + $value_as_string = is_scalar( $raw_value ) ? (string) $raw_value : '';
831 995 $optional_args = $args['optional_args'] ?? array();
832 996 $id = esc_attr( $name );
833 - $name = Parsely::OPTIONS_KEY . "[$id]";
997 + $html_name = $this->get_html_name_attribute( $name );
834 998 $is_obfuscated_value = $optional_args['is_obfuscated_value'] ?? false;
835 - $value = $is_obfuscated_value ? $this->get_obfuscated_value( $value ) : esc_attr( $value );
999 + $value = $is_obfuscated_value ? $this->get_obfuscated_value( $value_as_string ) : esc_attr( $value_as_string );
1000 + $type = $optional_args['type'] ?? 'text';
836 1001 $accepted_args = array( 'placeholder', 'required', 'disabled' );
837 - $type = $optional_args['type'] ?? 'text';
838 1002
1003 + if ( 'number' === $type ) {
1004 + $accepted_args = array_merge( $accepted_args, array( 'min', 'max', 'step' ) );
1005 + }
1006 +
839 1007 $is_managed = key_exists( $id, $this->parsely->managed_options );
840 1008 echo '<fieldset', $is_managed ? ' disabled>' : '>';
841 - printf( "<input type='%s' name='%s' id='%s' value='%s'", esc_attr( $type ), esc_attr( $name ), esc_attr( $id ), esc_attr( $value ) );
1009 + printf( "<input type='%s' name='%s' id='%s' value='%s'", esc_attr( $type ), esc_attr( $html_name ), esc_attr( $id ), esc_attr( $value ) );
842 1010
843 1011 if ( isset( $args['help_text'] ) ) {
844 1012 echo ' aria-describedby="' . esc_attr( $id ) . '-description"';
845 1013 }
@@ -875,22 +1043,12 @@
875 1043 $options = $options ?? $this->parsely->get_options();
876 1044 $name = $args['option_key'];
877 1045 $has_fieldset = isset( $args['add_fieldset'] ) && true === $args['add_fieldset'];
878 1046 $html_id = rtrim( str_replace( array( '[', ']', '__' ), '_', $name ), '_' );
879 - $html_name = str_replace(
880 - 'content_helper',
881 - '[content_helper]',
882 - Parsely::OPTIONS_KEY . esc_attr( $name )
883 - );
1047 + $html_name = $this->get_html_name_attribute( $name );
884 1048 $yes_text = $args['yes_text'] ?? '';
1049 + $value = $this->get_option_value( $name, $options );
885 1050
886 - // Get option value.
887 - if ( false === strpos( $name, '[' ) ) {
888 - $value = $options[ $name ];
889 - } else {
890 - $value = Parsely::get_nested_option_value( $name, $options );
891 - }
892 -
893 1051 // Fieldset start.
894 1052 if ( $has_fieldset ) {
895 1053 echo '<fieldset>';
896 1054 if ( isset( $args['legend'] ) ) {
@@ -920,9 +1078,9 @@
920 1078 $this->print_description_text( $args );
921 1079 }
922 1080
923 1081 /**
924 - * Prints a Content Helper AI feature section.
1082 + * Prints a Content Intelligence AI feature section.
925 1083 *
926 1084 * @since 3.16.0
927 1085 *
928 1086 * @param Setting_Arguments $args The arguments for the section.
@@ -980,12 +1138,110 @@
980 1138 }
981 1139
982 1140 echo '</fieldset>';
983 1141
1142 + if ( in_array( $feature_id, self::FEATURES_WITH_DEFAULTS, true ) ) {
1143 + $this->print_suggestion_defaults( $args, $options, $feature_id );
1144 + }
1145 +
984 1146 $this->print_filter_text( $args );
985 1147 }
986 1148
987 1149 /**
1150 + * Prints out a feature's site-wide generation defaults.
1151 + *
1152 + * These apply to users who have not yet set their own values in the editor.
1153 + * The desired length is printed for Excerpt Suggestions only, as Title
1154 + * Suggestions has no equivalent setting.
1155 + *
1156 + * @since 3.24.0
1157 + *
1158 + * @param Setting_Arguments $args The arguments for the fieldset.
1159 + * @param Parsely_Options $options The plugin's options.
1160 + * @param string $feature_id The feature's name.
1161 + */
1162 + private function print_suggestion_defaults( $args, $options, string $feature_id ): void {
1163 + /** @var array<string, mixed> $feature_options */
1164 + $feature_options = $options['content_helper'][ $feature_id ] ?? array();
1165 + $is_managed = key_exists( 'content_helper', $this->parsely->managed_options );
1166 +
1167 + echo '<fieldset class="', esc_attr( str_replace( '_', '-', $feature_id ) ), '-defaults"',
1168 + $is_managed ? ' disabled>' : '>';
1169 + echo '<legend class="screen-reader-text"><span>';
1170 + printf(
1171 + /* translators: %s: Feature name */
1172 + esc_html__( '%s Default Settings', 'wp-parsely' ),
1173 + esc_html( $args['legend'] ?? __( 'Feature', 'wp-parsely' ) )
1174 + );
1175 + echo '</span></legend>';
1176 +
1177 + if ( Excerpt_Suggestions::get_feature_name() === $feature_id ) {
1178 + $length_key = $args['option_key'] . '[default_length]';
1179 + printf(
1180 + '<p><label for="%1$s">%2$s</label><br />' .
1181 + '<input type="number" name="%3$s" id="%1$s" value="%4$d" min="%5$d" max="%6$d" step="1" class="small-text" /></p>',
1182 + esc_attr( $length_key ),
1183 + esc_html__( 'Default length (characters)', 'wp-parsely' ),
1184 + esc_attr( $this->get_html_name_attribute( $length_key ) ),
1185 + absint( Suggestion_Defaults::get_default_length( $feature_options ) ),
1186 + absint( Suggestion_Defaults::MIN_LENGTH ),
1187 + absint( Suggestion_Defaults::MAX_LENGTH )
1188 + );
1189 + }
1190 +
1191 + $this->print_suggestion_defaults_select(
1192 + $args['option_key'] . '[default_tone]',
1193 + __( 'Default tone', 'wp-parsely' ),
1194 + Suggestion_Defaults::get_tones(),
1195 + Suggestion_Defaults::get_default_tone( $feature_options )
1196 + );
1197 +
1198 + $this->print_suggestion_defaults_select(
1199 + $args['option_key'] . '[default_persona]',
1200 + __( 'Default persona', 'wp-parsely' ),
1201 + Suggestion_Defaults::get_personas(),
1202 + Suggestion_Defaults::get_default_persona( $feature_options )
1203 + );
1204 +
1205 + echo '</fieldset>';
1206 + }
1207 +
1208 + /**
1209 + * Prints out a select tag for a feature's default generation setting.
1210 + *
1211 + * @since 3.24.0
1212 + *
1213 + * @param string $option_key The nested option key.
1214 + * @param string $label The visible label.
1215 + * @param array<string, string> $choices The choices, as value => label pairs.
1216 + * @param string $selected The currently selected value.
1217 + */
1218 + private function print_suggestion_defaults_select(
1219 + string $option_key,
1220 + string $label,
1221 + array $choices,
1222 + string $selected
1223 + ): void {
1224 + printf(
1225 + '<p><label for="%1$s">%2$s</label><br /><select name="%3$s" id="%1$s">',
1226 + esc_attr( $option_key ),
1227 + esc_html( $label ),
1228 + esc_attr( $this->get_html_name_attribute( $option_key ) )
1229 + );
1230 +
1231 + foreach ( $choices as $value => $choice_label ) {
1232 + printf(
1233 + '<option value="%s"%s>%s</option>',
1234 + esc_attr( $value ),
1235 + selected( $selected, $value, false ),
1236 + esc_html( $choice_label )
1237 + );
1238 + }
1239 +
1240 + echo '</select></p>';
1241 + }
1242 +
1243 + /**
988 1244 * Prints out the select tags
989 1245 *
990 1246 * @param Setting_Arguments $args The arguments for the select dropdowns.
991 1247 */
@@ -1022,11 +1278,13 @@
1022 1278 *
1023 1279 * @param Setting_Arguments $args The arguments for the radio buttons.
1024 1280 */
1025 1281 public function print_radio_tags( $args ): void {
1282 + $options = $this->parsely->get_options();
1026 1283 $name = $args['option_key'];
1027 1284 $id = esc_attr( $name );
1028 - $selected = $this->parsely->get_options()[ $name ];
1285 + $selected = $this->get_option_value( $name, $options );
1286 + $html_name = $this->get_html_name_attribute( $name );
1029 1287 $title = $args['title'] ?? '';
1030 1288 $radio_options = $args['radio_options'] ?? array();
1031 1289
1032 1290 if ( is_bool( $selected ) ) {
@@ -1042,9 +1300,9 @@
1042 1300 <?php foreach ( $radio_options as $value => $text ) { ?>
1043 1301 <label for="<?php echo esc_attr( "{$id}_{$value}" ); ?>">
1044 1302 <input
1045 1303 type="radio"
1046 - name="<?php echo esc_attr( Parsely::OPTIONS_KEY . "[$id]" ); ?>"
1304 + name="<?php echo esc_attr( $html_name ); ?>"
1047 1305 id="<?php echo esc_attr( "{$id}_{$value}" ); ?>"
1048 1306 value="<?php echo esc_attr( $value ); ?>"
1049 1307 <?php checked( $selected, $value ); ?>
1050 1308 />
@@ -1090,8 +1348,55 @@
1090 1348 $this->print_description_text( $args );
1091 1349 }
1092 1350
1093 1351 /**
1352 + * Generates the HTML name attribute for a form field.
1353 + *
1354 + * Handles nested options (content_helper, headline_testing) and regular
1355 + * options, properly escaping and formatting the attribute value.
1356 + *
1357 + * @since 3.21.0
1358 + *
1359 + * @param string $name The option key/name.
1360 + * @return string The properly formatted and escaped HTML name attribute value.
1361 + */
1362 + private function get_html_name_attribute( string $name ): string {
1363 + if ( strpos( $name, 'content_helper' ) === 0 ) {
1364 + return Parsely::OPTIONS_KEY . str_replace(
1365 + 'content_helper',
1366 + '[content_helper]',
1367 + esc_attr( $name )
1368 + );
1369 + } elseif ( strpos( $name, 'headline_testing' ) === 0 ) {
1370 + return Parsely::OPTIONS_KEY . str_replace(
1371 + 'headline_testing',
1372 + '[headline_testing]',
1373 + esc_attr( $name )
1374 + );
1375 + } else {
1376 + return Parsely::OPTIONS_KEY . '[' . esc_attr( $name ) . ']';
1377 + }
1378 + }
1379 +
1380 + /**
1381 + * Gets the value of an option, handling both flat and nested option keys.
1382 + *
1383 + * @since 3.21.0
1384 + *
1385 + * @param string $name The option key name (may contain brackets for nested options).
1386 + * @param Parsely_Options $options The options array to retrieve from.
1387 + * @return mixed The option value, or null if not found.
1388 + */
1389 + private function get_option_value( string $name, $options ) {
1390 + // Get raw value based on whether it's a nested option or not.
1391 + if ( false === strpos( $name, '[' ) ) {
1392 + return $options[ $name ] ?? null;
1393 + } else {
1394 + return Parsely::get_nested_option_value( $name, $options ) ?? null;
1395 + }
1396 + }
1397 +
1398 + /**
1094 1399 * Prints out the post tracking options table.
1095 1400 *
1096 1401 * @since 3.2.0
1097 1402 *
@@ -1188,8 +1493,9 @@
1188 1493 */
1189 1494 public function validate_options( $input ) {
1190 1495 $input = $this->validate_basic_section( $input );
1191 1496 $input = $this->validate_content_helper_section( $input );
1497 + $input = $this->validate_headline_testing_section( $input );
1192 1498 $input = $this->validate_recrawl_section( $input );
1193 1499 $input = $this->validate_advanced_section( $input );
1194 1500
1195 1501 return $input;
@@ -1200,9 +1506,9 @@
1200 1506 *
1201 1507 * @param ParselySettingOptions $input Options from the settings page.
1202 1508 * @return ParselySettingOptions Validated inputs.
1203 1509 */
1204 - private function validate_basic_section( $input ) {
1510 + private function validate_basic_section( $input ): array {
1205 1511 $are_credentials_managed = $this->parsely->are_credentials_managed;
1206 1512 $options = $this->parsely->get_options();
1207 1513
1208 1514 if ( $are_credentials_managed ) {
@@ -1219,9 +1525,12 @@
1219 1525 if ( isset( $_POST['e2e_parsely_skip_api_validate'] ) && 'y' === $_POST['e2e_parsely_skip_api_validate'] ) {
1220 1526 $valid_credentials = true;
1221 1527 }
1222 1528
1223 - if ( is_wp_error( $valid_credentials ) && Validator::INVALID_API_CREDENTIALS === $valid_credentials->get_error_code() ) {
1529 + if (
1530 + ( is_wp_error( $valid_credentials ) && Validator::INVALID_API_CREDENTIALS === $valid_credentials->get_error_code() ) ||
1531 + ( is_bool( $valid_credentials ) && ! $valid_credentials )
1532 + ) {
1224 1533 add_settings_error(
1225 1534 Parsely::OPTIONS_KEY,
1226 1535 'api_secret',
1227 1536 __( 'The Site ID and API Secret weren\'t saved as they failed to authenticate with the Parse.ly API. Try again with different credentials or contact Parse.ly support', 'wp-parsely' )
@@ -1290,9 +1599,9 @@
1290 1599 return $input;
1291 1600 }
1292 1601
1293 1602 /**
1294 - * Validates the fields of the Content Helper section.
1603 + * Validates the fields of the Content Intelligence section.
1295 1604 *
1296 1605 * @since 3.16.0
1297 1606 *
1298 1607 * @param ParselySettingOptions $input The settings page options.
@@ -1299,9 +1608,9 @@
1299 1608 * @return ParselySettingOptions The validated input.
1300 1609 */
1301 1610 private function validate_content_helper_section( $input ) {
1302 1611 /**
1303 - * Sanitizes the Content Helper data.
1612 + * Sanitizes the Content Intelligence data.
1304 1613 *
1305 1614 * @since 3.16.0
1306 1615 */
1307 1616 $sanitize = function ( $input ) use ( &$sanitize ) {
@@ -1363,13 +1672,131 @@
1363 1672 }
1364 1673
1365 1674 // Produce the final array.
1366 1675 $options = $this->parsely->get_options()['content_helper'];
1367 - $merged = array_merge( $options, $input['content_helper'] );
1368 1676
1677 + // Validate the generation defaults separately, as the sanitizer above
1678 + // coerces every scalar into a boolean. Values that were not submitted
1679 + // fall back to the stored ones, and anything invalid falls back to the
1680 + // shipped default.
1681 + $validated_defaults = array();
1682 + foreach ( self::FEATURES_WITH_DEFAULTS as $feature_id ) {
1683 + /** @var array<string, mixed> $submitted */
1684 + $submitted = $input['content_helper'][ $feature_id ] ?? array();
1685 + /** @var array<string, mixed> $stored */
1686 + $stored = $options[ $feature_id ];
1687 +
1688 + $defaults = array(
1689 + 'default_tone' => Suggestion_Defaults::get_default_tone(
1690 + array( 'default_tone' => $submitted['default_tone'] ?? $stored['default_tone'] ?? null )
1691 + ),
1692 + 'default_persona' => Suggestion_Defaults::get_default_persona(
1693 + array( 'default_persona' => $submitted['default_persona'] ?? $stored['default_persona'] ?? null )
1694 + ),
1695 + );
1696 +
1697 + // Only Excerpt Suggestions has a desired length.
1698 + if ( Excerpt_Suggestions::get_feature_name() === $feature_id ) {
1699 + $length = $submitted['default_length'] ?? $stored['default_length'] ?? null;
1700 +
1701 + // Anything that is not an integer is invalid, rather than
1702 + // something to coerce into one.
1703 + $length = is_scalar( $length ) ? filter_var( $length, FILTER_VALIDATE_INT ) : false;
1704 +
1705 + $defaults['default_length'] = Suggestion_Defaults::get_default_length(
1706 + array( 'default_length' => false === $length ? null : $length )
1707 + );
1708 + }
1709 +
1710 + $validated_defaults[ $feature_id ] = $defaults;
1711 + }
1712 +
1713 + $merged = array_merge( $options, $input['content_helper'] );
1714 +
1369 1715 $input['content_helper'] = $sanitize( $merged );
1370 1716
1717 + foreach ( $validated_defaults as $feature_id => $defaults ) {
1718 + $input['content_helper'][ $feature_id ] = array_merge(
1719 + $input['content_helper'][ $feature_id ],
1720 + $defaults
1721 + );
1722 + }
1723 +
1371 1724 return $input;
1725 + }
1726 +
1727 + /**
1728 + * Validates fields of Headline Testing Section.
1729 + *
1730 + * @since 3.21.0
1731 + *
1732 + * @param ParselySettingOptions $input Options from the settings page.
1733 + * @return ParselySettingOptions Validated inputs.
1734 + */
1735 + private function validate_headline_testing_section( $input ) {
1736 + /**
1737 + * Sanitizes the Headline Testing data.
1738 + *
1739 + * @since 3.21.0
1740 + */
1741 + $sanitize = function ( $input ) use ( &$sanitize ) {
1742 + foreach ( $input as $key => $value ) {
1743 + if ( is_array( $value ) ) {
1744 + // Recurse for nested arrays.
1745 + $input[ $key ] = $sanitize( $value );
1746 + } else {
1747 + $input[ $key ] = $this->sanitize_headline_testing_field( $key, $value );
1748 + }
1749 + }
1750 +
1751 + return $input;
1752 + };
1753 +
1754 + // Initialize headline_testing array if it doesn't exist.
1755 + if ( ! isset( $input['headline_testing'] ) ) {
1756 + $input['headline_testing'] = array();
1757 + }
1758 +
1759 + // Ensure all required keys exist with defaults.
1760 + $input['headline_testing'] = array_merge(
1761 + $this->parsely->get_default_options()['headline_testing'],
1762 + $input['headline_testing']
1763 + );
1764 +
1765 + // Produce the final array.
1766 + $options = $this->parsely->get_options()['headline_testing'];
1767 + $merged = array_merge( $options, $input['headline_testing'] );
1768 +
1769 + $input['headline_testing'] = $sanitize( $merged );
1770 +
1771 + return $input;
1772 + }
1773 +
1774 + /**
1775 + * Sanitizes headline testing field values.
1776 + *
1777 + * @since 3.21.0
1778 + *
1779 + * @param string $key The field key.
1780 + * @param mixed $value The field value.
1781 + * @return mixed The sanitized value.
1782 + */
1783 + private function sanitize_headline_testing_field( string $key, $value ) {
1784 + switch ( $key ) {
1785 + case 'enabled':
1786 + case 'enable_flicker_control':
1787 + case 'enable_live_updates':
1788 + case 'allow_after_content_load':
1789 + return 'true' === $value || true === $value;
1790 + case 'installation_method':
1791 + $valid_methods = array( 'one_line', 'advanced' );
1792 + return in_array( $value, $valid_methods, true ) ? $value : 'one_line';
1793 + case 'live_update_timeout':
1794 + $timeout = intval( is_scalar( $value ) ? (string) $value : '0' );
1795 + return ( $timeout >= 1000 && $timeout <= 60000 ) ? $timeout : 30000;
1796 + default:
1797 + return sanitize_text_field( is_scalar( $value ) ? (string) $value : '' );
1798 + }
1372 1799 }
1373 1800
1374 1801 /**
1375 1802 * Validates fields of Recrawl Section.