PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.4
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.4
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 28.4, at src/abilities/infrastructure/post-seo-field-map.php

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