Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 128 additions & 20 deletions src/abilities/infrastructure/post-seo-field-map.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Yoast\WP\SEO\Abilities\Infrastructure;

use WPSEO_Rank;
use Yoast\WP\SEO\Config\Schema_Types;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
use Yoast\WP\SEO\Surfaces\Values\Meta;
Expand All @@ -14,7 +15,8 @@
* This is the single source of truth for the field contract shared by the read
* (collector) and write (updater) abilities. The write path applies the input
* onto an indexable; persistence to post meta is delegated to
* Indexable_To_Postmeta_Helper so the encodings live in one place.
* Indexable_To_Postmeta_Helper so the encodings live in one place. The same definitions

Check failure on line 18 in src/abilities/infrastructure/post-seo-field-map.php

View workflow job for this annotation

GitHub Actions / Check code style

Whitespace found at end of line

Check failure on line 18 in src/abilities/infrastructure/post-seo-field-map.php

View workflow job for this annotation

GitHub Actions / Check code style

Whitespace found at end of line
* drive both the ability input schema and the write.
*/
class Post_SEO_Field_Map {

Expand All @@ -35,24 +37,6 @@
'twitter_description' => 'twitter_description',
];

/**
* Maps the string input fields to the indexable column they write to.
*
* @var array<string, string>
*/
private const STRING_FIELDS = [
'seo_title' => 'title',
'meta_description' => 'description',
'focus_keyphrase' => 'primary_focus_keyword',
'canonical' => 'canonical',
'open_graph_title' => 'open_graph_title',
'open_graph_description' => 'open_graph_description',
'twitter_title' => 'twitter_title',
'twitter_description' => 'twitter_description',
'schema_page_type' => 'schema_page_type',
'schema_article_type' => 'schema_article_type',
];

/**
* Maps the boolean input fields to the indexable column they write to.
*
Expand Down Expand Up @@ -85,6 +69,129 @@
$this->meta_surface = $meta_surface;
}

// phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- The JSON schema arrays are heterogeneous by nature.

/**
* Returns the string fields editable through the update post SEO data ability.
*
* Each definition maps the ability input field name to the indexable column the
* value is written to and the JSON-schema fragment describing the field in the
* ability input schema. Keeping both in one definition means a field is only
* accepted by the input schema when the write path also knows how to apply it,
* so the two cannot drift apart.
*
* @return array<string, array<string, mixed>> The field definitions, keyed by input field name.
*/
public function get_editable_string_fields(): array {
$fields = [
'canonical' => [
'column' => 'canonical',
'schema' => [ 'type' => [ 'string', 'null' ] ],
],
'schema_page_type' => [
'column' => 'schema_page_type',
'schema' => $this->nullable_enum_schema(
\array_keys( Schema_Types::PAGE_TYPES ),
\__( 'The Schema.org page type for the post. Must be one of the supported page types. Use null to clear it and fall back to the default.', 'wordpress-seo' ),
),
],
'schema_article_type' => [
'column' => 'schema_article_type',
'schema' => $this->nullable_enum_schema(
$this->get_schema_article_types(),
\__( 'The Schema.org article type for the post. Must be one of the supported article types. Use null to clear it and fall back to the default.', 'wordpress-seo' ),
),
],
];

/**
* Filter: 'wpseo_editable_post_seo_data_string_fields' - Allows adding string fields to the
* ones editable through the update post SEO data ability.
*
* Each entry is keyed by the ability input field name and defines both the indexable column
* the value is written to ('column') and the JSON-schema fragment describing the field in
* the ability input schema ('schema'). Entries missing either part, or keyed by a reserved
* input field name, are discarded.
*
* @param array<string, array<string, mixed>> $fields The editable string field definitions.
*/
$filtered = \apply_filters( 'wpseo_editable_post_seo_data_string_fields', $fields );

if ( ! \is_array( $filtered ) ) {
return $fields;
}

return \array_filter( $filtered, [ $this, 'is_valid_string_field' ], \ARRAY_FILTER_USE_BOTH );
}

/**
* Checks whether a filtered field definition is usable.
*
* Guards the schema and write paths against malformed filter additions: an entry is
* only kept when it targets a non-reserved input field and defines both the indexable
* column and the schema fragment.
*
* @param mixed $field The field definition to check.
* @param mixed $input_key The input field name the definition is keyed by.
*
* @return bool Whether the definition is usable.
*/
private function is_valid_string_field( $field, $input_key ): bool {
if ( ! \is_string( $input_key ) || $input_key === '' ) {
return false;
}

// Never let a filtered definition hijack the post identifiers or the non-string fields.
$reserved = \array_merge( [ 'post_id', 'permalink', 'noindex' ], \array_keys( self::BOOLEAN_FIELDS ) );
if ( \in_array( $input_key, $reserved, true ) ) {
return false;
}

return \is_array( $field )
&& isset( $field['column'] ) && \is_string( $field['column'] ) && $field['column'] !== ''
&& isset( $field['schema'] ) && \is_array( $field['schema'] ) && $field['schema'] !== [];
}

/**
* Returns the allowed Schema.org article type values.
*
* Mirrors the validation in WPSEO_Option_Titles so the ability accepts exactly the
* article types the editor does, including any registered through the filter.
*
* @return array<int, string> The allowed article type values.
*/
private function get_schema_article_types(): array {
/**
* Filter: 'wpseo_schema_article_types' - Allow developers to filter the available article types.
*
* Make sure when you filter this to also filter `wpseo_schema_article_types_labels`.
*
* @param array $schema_article_types The available schema article types.
*/
return \array_keys( \apply_filters( 'wpseo_schema_article_types', Schema_Types::ARTICLE_TYPES ) );
}

/**
* Returns a nullable-string input schema constrained to a fixed set of allowed values.
*
* Null and the empty string are always allowed on top of the enum so the field can be
* cleared, matching the patch-clear semantics of the other write fields.
*
* @param array<int, string> $allowed_values The allowed string values.
* @param string $description The field description.
*
* @return array<string, mixed> The input schema fragment.
*/
private function nullable_enum_schema( array $allowed_values, string $description ): array {
return [
'type' => [ 'string', 'null' ],
'description' => $description,
'enum' => \array_merge( $allowed_values, [ '', null ] ),
];
}

// phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint

/**
* Builds the post SEO data array from an indexable.
*
Expand Down Expand Up @@ -200,9 +307,10 @@
public function apply_to_indexable( array $input, Indexable $indexable ): array {
$changed_columns = [];

foreach ( self::STRING_FIELDS as $input_key => $column ) {
foreach ( $this->get_editable_string_fields() as $input_key => $field ) {
if ( \array_key_exists( $input_key, $input ) ) {
$value = $input[ $input_key ];
$column = $field['column'];
$indexable->{$column} = ( $value === null || $value === '' ) ? null : (string) $value;
$changed_columns[] = $column;
}
Expand Down
120 changes: 44 additions & 76 deletions src/abilities/user-interface/abilities-integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
use Yoast\WP\SEO\Abilities\Application\Post_SEO_Data_Collector;
use Yoast\WP\SEO\Abilities\Application\Post_SEO_Data_Updater;
use Yoast\WP\SEO\Abilities\Application\Score_Retriever;
use Yoast\WP\SEO\Abilities\Infrastructure\Post_SEO_Field_Map;
use Yoast\WP\SEO\Conditionals\Abilities_API_Conditional;
use Yoast\WP\SEO\Conditionals\Should_Index_Indexables_Conditional;
use Yoast\WP\SEO\Config\Schema_Types;
use Yoast\WP\SEO\Editors\Application\Analysis_Features\Enabled_Analysis_Features_Repository;
use Yoast\WP\SEO\Editors\Framework\Inclusive_Language_Analysis;
use Yoast\WP\SEO\Editors\Framework\Keyphrase_Analysis;
Expand Down Expand Up @@ -56,6 +56,13 @@ class Abilities_Integration implements Integration_Interface {
*/
private $post_seo_data_updater;

/**
* The post SEO field map.
*
* @var Post_SEO_Field_Map
*/
private $post_seo_field_map;

/**
* Returns the conditionals based on which this loadable should be active.
*
Expand All @@ -76,19 +83,22 @@ public static function get_conditionals() {
* @param Enabled_Analysis_Features_Repository $enabled_analysis_features_repository The enabled analysis features repository.
* @param Post_SEO_Data_Collector $post_seo_data_collector The post SEO data collector.
* @param Post_SEO_Data_Updater $post_seo_data_updater The post SEO data updater.
* @param Post_SEO_Field_Map $post_seo_field_map The post SEO field map.
*/
public function __construct(
Score_Retriever $score_retriever,
Capability_Helper $capability_helper,
Enabled_Analysis_Features_Repository $enabled_analysis_features_repository,
Post_SEO_Data_Collector $post_seo_data_collector,
Post_SEO_Data_Updater $post_seo_data_updater
Post_SEO_Data_Updater $post_seo_data_updater,
Post_SEO_Field_Map $post_seo_field_map
) {
$this->score_retriever = $score_retriever;
$this->capability_helper = $capability_helper;
$this->enabled_analysis_features_repository = $enabled_analysis_features_repository;
$this->post_seo_data_collector = $post_seo_data_collector;
$this->post_seo_data_updater = $post_seo_data_updater;
$this->post_seo_field_map = $post_seo_field_map;
}

/**
Expand Down Expand Up @@ -125,6 +135,10 @@ public function register_abilities() {
if ( $enabled_features[ Inclusive_Language_Analysis::NAME ] === true ) {
$this->register_inclusive_language_scores_ability();
}

// Metadata read/write is independent of which analysis features are enabled.
$this->register_get_post_seo_data_ability();
$this->register_update_post_seo_data_ability();
}

/**
Expand Down Expand Up @@ -404,88 +418,42 @@ private function get_post_identifier_input_schema(): array {
/**
* Returns the input schema for updating a post's SEO data (write path).
*
* The string field properties come from the field map's editable field
* definitions, so the schema always matches what the write path applies.
*
* @return array<string, mixed> The input schema.
*/
private function get_update_post_seo_data_input_schema(): array {
$nullable_string = [ 'type' => [ 'string', 'null' ] ];

return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'post_id' => [
'type' => 'integer',
'description' => \__( 'The ID of the post to update.', 'wordpress-seo' ),
'minimum' => 1,
],
'permalink' => [
'type' => 'string',
'description' => \__( 'The permalink (URL) of the post to update.', 'wordpress-seo' ),
],
'seo_title' => $nullable_string,
'meta_description' => $nullable_string,
'focus_keyphrase' => \array_merge( $nullable_string, [ 'maxLength' => 191 ] ),
'canonical' => $nullable_string,
'is_cornerstone' => [ 'type' => 'boolean' ],
'noindex' => [
'type' => [ 'boolean', 'null' ],
'description' => \__( 'Whether search engines should be told not to index this post. true sets noindex (the post is excluded from search results); false forces the post to be indexed; null clears the setting and falls back to the post-type default.', 'wordpress-seo' ),
],
'nofollow' => [ 'type' => 'boolean' ],
'noimageindex' => [ 'type' => 'boolean' ],
'noarchive' => [ 'type' => 'boolean' ],
'nosnippet' => [ 'type' => 'boolean' ],
'open_graph_title' => $nullable_string,
'open_graph_description' => $nullable_string,
'twitter_title' => $nullable_string,
'twitter_description' => $nullable_string,
'schema_page_type' => $this->nullable_enum_schema(
\array_keys( Schema_Types::PAGE_TYPES ),
\__( 'The Schema.org page type for the post. Must be one of the supported page types. Use null to clear it and fall back to the default.', 'wordpress-seo' ),
),
'schema_article_type' => $this->nullable_enum_schema(
$this->get_schema_article_types(),
\__( 'The Schema.org article type for the post. Must be one of the supported article types. Use null to clear it and fall back to the default.', 'wordpress-seo' ),
),
$properties = [
'post_id' => [
'type' => 'integer',
'description' => \__( 'The ID of the post to update.', 'wordpress-seo' ),
'minimum' => 1,
],
'permalink' => [
'type' => 'string',
'description' => \__( 'The permalink (URL) of the post to update.', 'wordpress-seo' ),
],
];
}

/**
* Returns the allowed Schema.org article type values.
*
* Mirrors the validation in WPSEO_Option_Titles so the ability accepts exactly the
* article types the editor does, including any registered through the filter.
*
* @return array<int, string> The allowed article type values.
*/
private function get_schema_article_types(): array {
/**
* Filter: 'wpseo_schema_article_types' - Allow developers to filter the available article types.
*
* Make sure when you filter this to also filter `wpseo_schema_article_types_labels`.
*
* @param array $schema_article_types The available schema article types.
*/
return \array_keys( \apply_filters( 'wpseo_schema_article_types', Schema_Types::ARTICLE_TYPES ) );
}
foreach ( $this->post_seo_field_map->get_editable_string_fields() as $field_name => $field ) {
$properties[ $field_name ] = $field['schema'];
}

$properties['is_cornerstone'] = [ 'type' => 'boolean' ];
$properties['noindex'] = [
'type' => [ 'boolean', 'null' ],
'description' => \__( 'Whether search engines should be told not to index this post. true sets noindex (the post is excluded from search results); false forces the post to be indexed; null clears the setting and falls back to the post-type default.', 'wordpress-seo' ),
];
$properties['nofollow'] = [ 'type' => 'boolean' ];
$properties['noimageindex'] = [ 'type' => 'boolean' ];
$properties['noarchive'] = [ 'type' => 'boolean' ];
$properties['nosnippet'] = [ 'type' => 'boolean' ];

/**
* Returns a nullable-string input schema constrained to a fixed set of allowed values.
*
* Null and the empty string are always allowed on top of the enum so the field can be
* cleared, matching the patch-clear semantics of the other write fields.
*
* @param array<int, string> $allowed_values The allowed string values.
* @param string $description The field description.
*
* @return array<string, mixed> The input schema fragment.
*/
private function nullable_enum_schema( array $allowed_values, string $description ): array {
return [
'type' => [ 'string', 'null' ],
'description' => $description,
'enum' => \array_merge( $allowed_values, [ '', null ] ),
'type' => 'object',
'additionalProperties' => false,
'properties' => $properties,
];
}

Expand Down
Loading
Loading