PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.3 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 All 107 releases
← All changes | src/UI/class-settings-page.php +469 -41 3.18.0 → 3.24.2 View file →
@@ -10,8 +10,9 @@
10 10
11 11 namespace Parsely\UI;
12 12
13 13 use Parsely\Content_Helper\Excerpt_Suggestions;
14 +use Parsely\Content_Helper\Suggestion_Defaults;
14 15 use Parsely\Parsely;
15 16 use Parsely\Permissions;
16 17 use Parsely\Utils\Utils;
17 18 use Parsely\Validator;
@@ -61,8 +62,9 @@
61 62 * use_top_level_cats?:bool|string,
62 63 * custom_taxonomy_section?: string,
63 64 * cats_as_tags?: bool|string,
64 65 * content_helper: Parsely_Settings_Options_Content_Helper,
66 + * headline_testing?: Parsely_Options_Headline_Testing,
65 67 * lowercase_tags?: bool,
66 68 * force_https_canonicals?: bool,
67 69 * disable_autotrack?: bool|string,
68 70 * }
@@ -71,16 +73,21 @@
71 73 * ai_features_enabled?: bool,
72 74 * smart_linking?: Parsely_Settings_Options_Content_Helper_Feature,
73 75 * title_suggestions?: Parsely_Settings_Options_Content_Helper_Feature,
74 76 * excerpt_suggestions?: Parsely_Settings_Options_Content_Helper_Feature,
77 + * traffic_boost?: Parsely_Settings_Options_Content_Helper_Feature,
75 78 * }
76 79 *
77 80 * @phpstan-type Parsely_Settings_Options_Content_Helper_Feature array{
78 81 * enabled?: bool,
79 - * 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
80 86 * }
81 87 *
82 88 * @phpstan-import-type Parsely_Options from Parsely
89 + * @phpstan-import-type Parsely_Options_Headline_Testing from Parsely
83 90 */
84 91 final class Settings_Page {
85 92 /**
86 93 * Instance of Parsely class.
@@ -107,10 +114,24 @@
107 114 */
108 115 private $managed_options_badge = array();
109 116
110 117 /**
111 - * 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.
112 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 + *
113 134 * @since 3.16.0
114 135 *
115 136 * @var string[]
116 137 */
@@ -117,8 +138,9 @@
117 138 private $configurable_pch_features = array(
118 139 'smart_linking',
119 140 'title_suggestions',
120 141 'excerpt_suggestions',
142 + 'traffic_boost',
121 143 );
122 144
123 145 /**
124 146 * Constructor.
@@ -157,9 +179,9 @@
157 179 *
158 180 * @param string|null $hook_suffix The current page being loaded.
159 181 */
160 182 public function enqueue_settings_assets( ?string $hook_suffix ): void {
161 - if ( ! is_string( $hook_suffix ) || 'settings_page_parsely' !== $hook_suffix ) {
183 + if ( ! is_string( $hook_suffix ) || $this->hook_suffix !== $hook_suffix ) {
162 184 return;
163 185 }
164 186
165 187 add_filter( 'media_library_months_with_files', '__return_empty_array' );
@@ -165,9 +187,9 @@
165 187 add_filter( 'media_library_months_with_files', '__return_empty_array' );
166 188 wp_enqueue_media();
167 189
168 190 $admin_settings_asset = Utils::get_asset_info( 'build/admin-settings.asset.php' );
169 - $built_assets_url = plugin_dir_url( PARSELY_FILE ) . '/build/';
191 + $built_assets_url = plugin_dir_url( PARSELY_FILE ) . 'build/';
170 192
171 193 wp_enqueue_script(
172 194 'parsely-admin-settings',
173 195 $built_assets_url . 'admin-settings.js',
@@ -178,9 +200,9 @@
178 200
179 201 wp_enqueue_style(
180 202 'parsely-admin-settings',
181 203 $built_assets_url . 'admin-settings.css',
182 - $admin_settings_asset['dependencies'],
204 + array(),
183 205 $admin_settings_asset['version']
184 206 );
185 207 }
186 208
@@ -187,11 +209,12 @@
187 209 /**
188 210 * Adds the Parse.ly settings page in WordPress settings menu.
189 211 */
190 212 public function add_settings_sub_menu(): void {
191 - $suffix = add_options_page(
213 + $suffix = add_submenu_page(
214 + 'parsely-dashboard-page',
192 215 __( 'Parse.ly Settings', 'wp-parsely' ),
193 - __( 'Parse.ly', 'wp-parsely' ),
216 + __( 'Settings', 'wp-parsely' ),
194 217 Parsely::CAPABILITY, // phpcs:ignore WordPress.WP.Capabilities.Undetermined
195 218 Parsely::MENU_SLUG,
196 219 array( $this, 'display_settings' )
197 220 );
@@ -225,9 +248,9 @@
225 248 );
226 249 }
227 250
228 251 /**
229 - * Displays the Parse.ly settings screen (options-general.php?page=[SLUG]).
252 + * Displays the Parse.ly settings screen (admin.php?page=[SLUG]).
230 253 */
231 254 public function display_settings(): void {
232 255 // phpcs:ignore WordPress.WP.Capabilities.Undetermined
233 256 if ( ! current_user_can( Parsely::CAPABILITY ) ) {
@@ -257,8 +280,9 @@
257 280 );
258 281
259 282 $this->initialize_basic_section();
260 283 $this->initialize_content_helper_section();
284 + $this->initialize_headline_testing_section();
261 285 $this->initialize_recrawl_section();
262 286 $this->initialize_advanced_section();
263 287 }
264 288
@@ -425,18 +449,18 @@
425 449 }
426 450 }
427 451
428 452 /**
429 - * Registers the Content Helper section and its settings.
453 + * Registers section and settings for Content Intelligence section.
430 454 *
431 455 * @since 3.16.0
432 456 */
433 457 private function initialize_content_helper_section(): void {
434 - $section_key = 'content-helper-section';
458 + $section_key = 'content-intelligence-section';
435 459
436 460 add_settings_section(
437 461 $section_key,
438 - __( 'Content Helper', 'wp-parsely' ),
462 + __( 'Content Intelligence', 'wp-parsely' ),
439 463 '__return_null',
440 464 Parsely::MENU_SLUG
441 465 );
442 466
@@ -505,11 +529,159 @@
505 529 Parsely::MENU_SLUG,
506 530 $section_key,
507 531 $field_args
508 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 + );
509 549 }
510 550
511 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 + /**
512 684 * Registers section and settings for Recrawl section.
513 685 *
514 686 * @since 3.2.0
515 687 */
@@ -815,27 +987,27 @@
815 987 *
816 988 * @param Setting_Arguments $args The arguments for text tag.
817 989 */
818 990 public function print_text_tag( $args ): void {
819 - $options = $this->parsely->get_options();
820 - $name = $args['option_key'];
821 - /**
822 - * Variable.
823 - *
824 - * @var string
825 - */
826 - $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 : '';
827 995 $optional_args = $args['optional_args'] ?? array();
828 996 $id = esc_attr( $name );
829 - $name = Parsely::OPTIONS_KEY . "[$id]";
997 + $html_name = $this->get_html_name_attribute( $name );
830 998 $is_obfuscated_value = $optional_args['is_obfuscated_value'] ?? false;
831 - $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';
832 1001 $accepted_args = array( 'placeholder', 'required', 'disabled' );
833 - $type = $optional_args['type'] ?? 'text';
834 1002
1003 + if ( 'number' === $type ) {
1004 + $accepted_args = array_merge( $accepted_args, array( 'min', 'max', 'step' ) );
1005 + }
1006 +
835 1007 $is_managed = key_exists( $id, $this->parsely->managed_options );
836 1008 echo '<fieldset', $is_managed ? ' disabled>' : '>';
837 - 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 ) );
838 1010
839 1011 if ( isset( $args['help_text'] ) ) {
840 1012 echo ' aria-describedby="' . esc_attr( $id ) . '-description"';
841 1013 }
@@ -871,22 +1043,12 @@
871 1043 $options = $options ?? $this->parsely->get_options();
872 1044 $name = $args['option_key'];
873 1045 $has_fieldset = isset( $args['add_fieldset'] ) && true === $args['add_fieldset'];
874 1046 $html_id = rtrim( str_replace( array( '[', ']', '__' ), '_', $name ), '_' );
875 - $html_name = str_replace(
876 - 'content_helper',
877 - '[content_helper]',
878 - Parsely::OPTIONS_KEY . esc_attr( $name )
879 - );
1047 + $html_name = $this->get_html_name_attribute( $name );
880 1048 $yes_text = $args['yes_text'] ?? '';
1049 + $value = $this->get_option_value( $name, $options );
881 1050
882 - // Get option value.
883 - if ( false === strpos( $name, '[' ) ) {
884 - $value = $options[ $name ];
885 - } else {
886 - $value = Parsely::get_nested_option_value( $name, $options );
887 - }
888 -
889 1051 // Fieldset start.
890 1052 if ( $has_fieldset ) {
891 1053 echo '<fieldset>';
892 1054 if ( isset( $args['legend'] ) ) {
@@ -916,9 +1078,9 @@
916 1078 $this->print_description_text( $args );
917 1079 }
918 1080
919 1081 /**
920 - * Prints a Content Helper AI feature section.
1082 + * Prints a Content Intelligence AI feature section.
921 1083 *
922 1084 * @since 3.16.0
923 1085 *
924 1086 * @param Setting_Arguments $args The arguments for the section.
@@ -976,12 +1138,110 @@
976 1138 }
977 1139
978 1140 echo '</fieldset>';
979 1141
1142 + if ( in_array( $feature_id, self::FEATURES_WITH_DEFAULTS, true ) ) {
1143 + $this->print_suggestion_defaults( $args, $options, $feature_id );
1144 + }
1145 +
980 1146 $this->print_filter_text( $args );
981 1147 }
982 1148
983 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 + /**
984 1244 * Prints out the select tags
985 1245 *
986 1246 * @param Setting_Arguments $args The arguments for the select dropdowns.
987 1247 */
@@ -1018,11 +1278,13 @@
1018 1278 *
1019 1279 * @param Setting_Arguments $args The arguments for the radio buttons.
1020 1280 */
1021 1281 public function print_radio_tags( $args ): void {
1282 + $options = $this->parsely->get_options();
1022 1283 $name = $args['option_key'];
1023 1284 $id = esc_attr( $name );
1024 - $selected = $this->parsely->get_options()[ $name ];
1285 + $selected = $this->get_option_value( $name, $options );
1286 + $html_name = $this->get_html_name_attribute( $name );
1025 1287 $title = $args['title'] ?? '';
1026 1288 $radio_options = $args['radio_options'] ?? array();
1027 1289
1028 1290 if ( is_bool( $selected ) ) {
@@ -1038,9 +1300,9 @@
1038 1300 <?php foreach ( $radio_options as $value => $text ) { ?>
1039 1301 <label for="<?php echo esc_attr( "{$id}_{$value}" ); ?>">
1040 1302 <input
1041 1303 type="radio"
1042 - name="<?php echo esc_attr( Parsely::OPTIONS_KEY . "[$id]" ); ?>"
1304 + name="<?php echo esc_attr( $html_name ); ?>"
1043 1305 id="<?php echo esc_attr( "{$id}_{$value}" ); ?>"
1044 1306 value="<?php echo esc_attr( $value ); ?>"
1045 1307 <?php checked( $selected, $value ); ?>
1046 1308 />
@@ -1086,8 +1348,55 @@
1086 1348 $this->print_description_text( $args );
1087 1349 }
1088 1350
1089 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 + /**
1090 1399 * Prints out the post tracking options table.
1091 1400 *
1092 1401 * @since 3.2.0
1093 1402 *
@@ -1184,8 +1493,9 @@
1184 1493 */
1185 1494 public function validate_options( $input ) {
1186 1495 $input = $this->validate_basic_section( $input );
1187 1496 $input = $this->validate_content_helper_section( $input );
1497 + $input = $this->validate_headline_testing_section( $input );
1188 1498 $input = $this->validate_recrawl_section( $input );
1189 1499 $input = $this->validate_advanced_section( $input );
1190 1500
1191 1501 return $input;
@@ -1289,9 +1599,9 @@
1289 1599 return $input;
1290 1600 }
1291 1601
1292 1602 /**
1293 - * Validates the fields of the Content Helper section.
1603 + * Validates the fields of the Content Intelligence section.
1294 1604 *
1295 1605 * @since 3.16.0
1296 1606 *
1297 1607 * @param ParselySettingOptions $input The settings page options.
@@ -1298,9 +1608,9 @@
1298 1608 * @return ParselySettingOptions The validated input.
1299 1609 */
1300 1610 private function validate_content_helper_section( $input ) {
1301 1611 /**
1302 - * Sanitizes the Content Helper data.
1612 + * Sanitizes the Content Intelligence data.
1303 1613 *
1304 1614 * @since 3.16.0
1305 1615 */
1306 1616 $sanitize = function ( $input ) use ( &$sanitize ) {
@@ -1362,13 +1672,131 @@
1362 1672 }
1363 1673
1364 1674 // Produce the final array.
1365 1675 $options = $this->parsely->get_options()['content_helper'];
1366 - $merged = array_merge( $options, $input['content_helper'] );
1367 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 +
1368 1715 $input['content_helper'] = $sanitize( $merged );
1369 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 +
1370 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 + }
1371 1799 }
1372 1800
1373 1801 /**
1374 1802 * Validates fields of Recrawl Section.