PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / abilities / infrastructure / post-seo-field-map.php

post-seo-field-map.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI trunk, at src/abilities/infrastructure/post-seo-field-map.php

228 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Abilities\Infrastructure;
5
6 use WPSEO_Rank;
7 use Yoast\WP\SEO\Models\Indexable;
8 use Yoast\WP\SEO\Surfaces\Meta_Surface;
9 use Yoast\WP\SEO\Surfaces\Values\Meta;
10
11 /**
12 * Translates between the ability input, an indexable, and the post SEO data value object.
13 *
14 * The read path (collector) maps every readable field onto the output array. The
15 * write path (updater) applies whatever validated input it receives onto an
16 * indexable; which fields are exposed for writing is decided by the ability's
17 * input schema, so the writable set may be narrower than the fields known here.
18 * Persistence to post meta is delegated to Indexable_To_Postmeta_Helper so the
19 * encodings live in one place.
20 */
21 class Post_SEO_Field_Map {
22
23 /**
24 * Maps each rendered output field to the Meta property holding the value as
25 * it is actually output on the front end (template applied, replacement
26 * variables expanded).
27 *
28 * @var array<string, string>
29 */
30 private const RENDERED_FIELDS = [
31 'seo_title' => 'title',
32 'meta_description' => 'meta_description',
33 'canonical' => 'canonical',
34 'open_graph_title' => 'open_graph_title',
35 'open_graph_description' => 'open_graph_description',
36 'twitter_title' => 'twitter_title',
37 'twitter_description' => 'twitter_description',
38 ];
39
40 /**
41 * Maps the string input fields to the indexable column they write to.
42 *
43 * @var array<string, string>
44 */
45 private const STRING_FIELDS = [
46 'seo_title' => 'title',
47 'meta_description' => 'description',
48 'canonical' => 'canonical',
49 'open_graph_title' => 'open_graph_title',
50 'open_graph_description' => 'open_graph_description',
51 'twitter_title' => 'twitter_title',
52 'twitter_description' => 'twitter_description',
53 'schema_page_type' => 'schema_page_type',
54 'schema_article_type' => 'schema_article_type',
55 ];
56
57 /**
58 * Maps the boolean input fields to the indexable column they write to.
59 *
60 * Excludes `noindex`, which is tri-state (null resets to the default) and is
61 * handled separately.
62 *
63 * @var array<string, string>
64 */
65 private const BOOLEAN_FIELDS = [
66 'is_cornerstone' => 'is_cornerstone',
67 'nofollow' => 'is_robots_nofollow',
68 'noimageindex' => 'is_robots_noimageindex',
69 'noarchive' => 'is_robots_noarchive',
70 'nosnippet' => 'is_robots_nosnippet',
71 ];
72
73 /**
74 * The meta surface.
75 *
76 * @var Meta_Surface
77 */
78 private $meta_surface;
79
80 /**
81 * Constructor.
82 *
83 * @param Meta_Surface $meta_surface The meta surface.
84 */
85 public function __construct( Meta_Surface $meta_surface ) {
86 $this->meta_surface = $meta_surface;
87 }
88
89 /**
90 * Builds the post SEO data array from an indexable.
91 *
92 * Alongside the raw stored fields, each rendered companion (`*_rendered`)
93 * carries the value as it is actually output on the front end: the global
94 * default template is applied where no custom value is set, and replacement
95 * variables are expanded.
96 *
97 * @param Indexable $indexable The indexable to read from.
98 *
99 * @return array<string, int|string|bool|null> The post SEO data, keyed by output-schema property name.
100 */
101 public function to_seo_array( $indexable ): array {
102 $meta = $this->meta_surface->for_indexable( $indexable, 'Post_Type' );
103
104 return [
105 'post_id' => (int) $indexable->object_id,
106 'post_title' => $indexable->breadcrumb_title,
107 'permalink' => $indexable->permalink,
108 'post_type' => $indexable->object_sub_type,
109 'post_status' => $indexable->post_status,
110 'seo_title' => $indexable->title,
111 'seo_title_rendered' => $this->rendered( $meta, 'seo_title' ),
112 'meta_description' => $indexable->description,
113 'meta_description_rendered' => $this->rendered( $meta, 'meta_description' ),
114 'focus_keyphrase' => $indexable->primary_focus_keyword,
115 'canonical' => $indexable->canonical,
116 'canonical_rendered' => $this->rendered( $meta, 'canonical' ),
117 'is_cornerstone' => (bool) $indexable->is_cornerstone,
118 'noindex' => $indexable->is_robots_noindex,
119 'nofollow' => (bool) $indexable->is_robots_nofollow,
120 'noimageindex' => (bool) $indexable->is_robots_noimageindex,
121 'noarchive' => (bool) $indexable->is_robots_noarchive,
122 'nosnippet' => (bool) $indexable->is_robots_nosnippet,
123 'open_graph_title' => $indexable->open_graph_title,
124 'open_graph_title_rendered' => $this->rendered( $meta, 'open_graph_title' ),
125 'open_graph_description' => $indexable->open_graph_description,
126 'open_graph_description_rendered' => $this->rendered( $meta, 'open_graph_description' ),
127 'twitter_title' => $indexable->twitter_title,
128 'twitter_title_rendered' => $this->rendered( $meta, 'twitter_title' ),
129 'twitter_description' => $indexable->twitter_description,
130 'twitter_description_rendered' => $this->rendered( $meta, 'twitter_description' ),
131 'schema_page_type' => $indexable->schema_page_type,
132 'schema_article_type' => $indexable->schema_article_type,
133 'seo_score' => WPSEO_Rank::from_numeric_score( (int) $indexable->primary_focus_keyword_score )->get_rank(),
134 'readability_score' => WPSEO_Rank::from_numeric_score( (int) $indexable->readability_score )->get_rank(),
135 // A zero score maps to NO_FOCUS ("not available") via the rank ranges, same as the scores above.
136 'inclusive_language_score' => WPSEO_Rank::from_numeric_score( (int) $indexable->inclusive_language_score )->get_rank(),
137 ];
138 }
139
140 /**
141 * Builds the post SEO data arrays for a set of indexables.
142 *
143 * @param Indexable[] $indexables The indexables to read from.
144 *
145 * @return array<int, array<string, int|string|bool|null>> The post SEO data for each indexable.
146 */
147 public function indexables_to_arrays( array $indexables ): array {
148 if ( $indexables !== [] ) {
149 $object_ids = \array_map(
150 static function ( $indexable ) {
151 return (int) $indexable->object_id;
152 },
153 $indexables,
154 );
155
156 // Prime the post cache in one query, so each presentation build below reads its post from cache instead of issuing its own query.
157 \_prime_post_caches( $object_ids, false, false );
158 }
159
160 return \array_map( [ $this, 'to_seo_array' ], $indexables );
161 }
162
163 /**
164 * Returns the front-end output for a rendered field, or null when nothing is output.
165 *
166 * @param Meta|false $meta The meta values for the post, or false when unavailable.
167 * @param string $output_field The rendered output field name.
168 *
169 * @return string|null The rendered value, or null when empty or unavailable.
170 */
171 private function rendered( $meta, string $output_field ): ?string {
172 if ( ! $meta instanceof Meta ) {
173 return null;
174 }
175
176 $value = $meta->{ self::RENDERED_FIELDS[ $output_field ] };
177
178 // Treat an empty presented value as "nothing is output" rather than an empty string.
179 if ( $value === null || $value === '' ) {
180 return null;
181 }
182
183 return (string) $value;
184 }
185
186 /**
187 * Applies a validated input patch onto an indexable.
188 *
189 * Only fields present in the input are touched (patch semantics); a present but
190 * empty/null value clears the field by setting its column to null. The mutated
191 * indexable is the desired state, which the caller cascades to post meta. Flags
192 * left out of the patch keep their current value, so advanced-robots flags merge
193 * for free.
194 *
195 * @param array<string, int|string|bool|null> $input The validated input patch.
196 * @param Indexable $indexable The indexable to mutate.
197 *
198 * @return array<string> The indexable columns the patch touched, so the caller can cascade
199 * only those to post meta.
200 */
201 public function apply_to_indexable( array $input, Indexable $indexable ): array {
202 $changed_columns = [];
203
204 foreach ( self::STRING_FIELDS as $input_key => $column ) {
205 if ( \array_key_exists( $input_key, $input ) ) {
206 $value = $input[ $input_key ];
207 $indexable->{$column} = ( $value === null || $value === '' ) ? null : (string) $value;
208 $changed_columns[] = $column;
209 }
210 }
211
212 foreach ( self::BOOLEAN_FIELDS as $input_key => $column ) {
213 if ( \array_key_exists( $input_key, $input ) ) {
214 $indexable->{$column} = (bool) $input[ $input_key ];
215 $changed_columns[] = $column;
216 }
217 }
218
219 if ( \array_key_exists( 'noindex', $input ) ) {
220 // Tri-state: null resets to the post-type default, true = noindex, false = index.
221 $indexable->is_robots_noindex = ( $input['noindex'] === null ) ? null : (bool) $input['noindex'];
222 $changed_columns[] = 'is_robots_noindex';
223 }
224
225 return $changed_columns;
226 }
227 }
228