Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.15% covered (warning)
78.15%
93 / 119
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Author_Schema_Node
78.15% covered (warning)
78.15%
93 / 119
33.33% covered (danger)
33.33%
3 / 9
41.02
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 build_person
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
11.03
 build_profile_page
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 render_profile_fields
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 save_profile_fields
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
8.70
 sanitize_url_list
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 text
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Author Person / ProfilePage Schema.org node builder.
4 *
5 * @package automattic/jetpack-seo-package
6 */
7
8namespace Automattic\Jetpack\SEO;
9
10use WP_User;
11
12/**
13 * Builds author Person / ProfilePage nodes and owns the extra author profile meta.
14 */
15class Author_Schema_Node {
16
17    /**
18     * User meta key for the author's job title.
19     */
20    const META_JOB_TITLE = 'jetpack_seo_job_title';
21
22    /**
23     * User meta key for profile links emitted as Person `sameAs`.
24     */
25    const META_SAME_AS = 'jetpack_seo_same_as';
26
27    /**
28     * Wire the WP user-profile fields.
29     *
30     * @return void
31     */
32    public static function init() {
33        register_meta(
34            'user',
35            self::META_JOB_TITLE,
36            array(
37                'type'              => 'string',
38                'single'            => true,
39                'default'           => '',
40                'sanitize_callback' => 'sanitize_text_field',
41                'show_in_rest'      => true,
42            )
43        );
44        register_meta(
45            'user',
46            self::META_SAME_AS,
47            array(
48                'type'              => 'array',
49                'single'            => true,
50                'default'           => array(),
51                'sanitize_callback' => array( __CLASS__, 'sanitize_url_list' ),
52                'show_in_rest'      => array(
53                    'schema' => array(
54                        'type'  => 'array',
55                        'items' => array(
56                            'type'   => 'string',
57                            'format' => 'uri',
58                        ),
59                    ),
60                ),
61            )
62        );
63
64        add_action( 'show_user_profile', array( __CLASS__, 'render_profile_fields' ) );
65        add_action( 'edit_user_profile', array( __CLASS__, 'render_profile_fields' ) );
66        add_action( 'personal_options_update', array( __CLASS__, 'save_profile_fields' ) );
67        add_action( 'edit_user_profile_update', array( __CLASS__, 'save_profile_fields' ) );
68    }
69
70    /**
71     * Build a Person node for a WP user.
72     *
73     * @param WP_User|int|null $user User object or ID.
74     * @return array|null
75     */
76    public static function build_person( $user ) {
77        $user = self::get_user( $user );
78        if ( ! $user ) {
79            return null;
80        }
81
82        $name = self::text( $user->display_name );
83        if ( '' === $name ) {
84            return null;
85        }
86
87        $node = array(
88            '@type' => 'Person',
89            '@id'   => Schema_Node_Ids::person( $user->ID, $user->user_nicename ),
90            'name'  => $name,
91        );
92
93        $image = get_avatar_url( $user->ID, array( 'size' => 512 ) );
94        if ( is_string( $image ) && '' !== $image ) {
95            $node['image'] = $image;
96        }
97
98        $description = self::text( get_the_author_meta( 'description', $user->ID ) );
99        if ( '' !== $description ) {
100            $node['description'] = $description;
101        }
102
103        // Prefer the user's Website field; fall back to the author archive so the
104        // Person always carries a URL identifying the author.
105        $url         = self::url( $user->user_url );
106        $node['url'] = '' !== $url ? $url : get_author_posts_url( $user->ID, $user->user_nicename );
107
108        $given_name = self::text( get_the_author_meta( 'first_name', $user->ID ) );
109        if ( '' !== $given_name ) {
110            $node['givenName'] = $given_name;
111        }
112
113        $family_name = self::text( get_the_author_meta( 'last_name', $user->ID ) );
114        if ( '' !== $family_name ) {
115            $node['familyName'] = $family_name;
116        }
117
118        $job_title = self::text( get_user_meta( $user->ID, self::META_JOB_TITLE, true ) );
119        if ( '' !== $job_title ) {
120            $node['jobTitle'] = $job_title;
121        }
122
123        $same_as = self::sanitize_url_list( get_user_meta( $user->ID, self::META_SAME_AS, true ) );
124        if ( ! empty( $same_as ) ) {
125            $node['sameAs'] = $same_as;
126        }
127
128        return $node;
129    }
130
131    /**
132     * Build a ProfilePage node for an author archive.
133     *
134     * @param WP_User|int|null $user User object or ID.
135     * @return array|null
136     */
137    public static function build_profile_page( $user ) {
138        $user = self::get_user( $user );
139        if ( ! $user || '' === self::text( $user->display_name ) ) {
140            return null;
141        }
142
143        return array(
144            '@type'      => 'ProfilePage',
145            '@id'        => Schema_Node_Ids::profile_page( $user->ID, $user->user_nicename ),
146            'url'        => get_author_posts_url( $user->ID, $user->user_nicename ),
147            'mainEntity' => array( '@id' => Schema_Node_Ids::person( $user->ID, $user->user_nicename ) ),
148        );
149    }
150
151    /**
152     * Render the Jetpack SEO author profile fields.
153     *
154     * @param WP_User $user User being edited.
155     * @return void
156     */
157    public static function render_profile_fields( WP_User $user ) {
158        $job_field   = self::META_JOB_TITLE;
159        $links_field = self::META_SAME_AS;
160        $job_title   = get_user_meta( $user->ID, $job_field, true );
161        $same_as     = self::sanitize_url_list( get_user_meta( $user->ID, $links_field, true ) );
162
163        printf(
164            '<h2>%1$s</h2>
165            <table class="form-table" role="presentation">
166                <tr>
167                    <th><label for="%2$s">%3$s</label></th>
168                    <td><input type="text" class="regular-text" name="%2$s" id="%2$s" value="%4$s" /></td>
169                </tr>
170                <tr>
171                    <th><label for="%5$s">%6$s</label></th>
172                    <td>
173                        <textarea class="large-text code" rows="5" name="%5$s" id="%5$s">%7$s</textarea>
174                        <p class="description">%8$s</p>
175                    </td>
176                </tr>
177            </table>',
178            esc_html__( 'Jetpack SEO author schema', 'jetpack-seo' ),
179            esc_attr( $job_field ),
180            esc_html__( 'Job title', 'jetpack-seo' ),
181            esc_attr( $job_title ),
182            esc_attr( $links_field ),
183            esc_html__( 'Profile links', 'jetpack-seo' ),
184            esc_textarea( implode( "\n", $same_as ) ),
185            esc_html__( 'One public profile URL per line.', 'jetpack-seo' )
186        );
187    }
188
189    /**
190     * Save the Jetpack SEO author profile fields.
191     *
192     * @param int $user_id User ID.
193     * @return void
194     */
195    public static function save_profile_fields( $user_id ) {
196        if ( ! current_user_can( 'edit_user', $user_id ) ) {
197            return;
198        }
199
200        $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
201        if ( ! wp_verify_nonce( $nonce, 'update-user_' . $user_id ) ) {
202            return;
203        }
204
205        $job_title = isset( $_POST[ self::META_JOB_TITLE ] )
206            ? self::text( sanitize_text_field( wp_unslash( $_POST[ self::META_JOB_TITLE ] ) ) )
207            : '';
208        if ( '' === $job_title ) {
209            delete_user_meta( $user_id, self::META_JOB_TITLE );
210        } else {
211            update_user_meta( $user_id, self::META_JOB_TITLE, $job_title );
212        }
213
214        $same_as = isset( $_POST[ self::META_SAME_AS ] )
215            ? preg_split( '/\r\n|\r|\n/', sanitize_textarea_field( wp_unslash( $_POST[ self::META_SAME_AS ] ) ) )
216            : array();
217        $same_as = self::sanitize_url_list( $same_as );
218        if ( empty( $same_as ) ) {
219            delete_user_meta( $user_id, self::META_SAME_AS );
220        } else {
221            update_user_meta( $user_id, self::META_SAME_AS, $same_as );
222        }
223    }
224
225    /**
226     * Normalize a list of URLs: keep unique absolute http(s) URLs. Delegates to
227     * the shared {@see Schema_Settings::sanitize_url_list()} sanitizer.
228     *
229     * @param mixed $value Raw value.
230     * @return array<int, string>
231     */
232    public static function sanitize_url_list( $value ) {
233        return Schema_Settings::sanitize_url_list( $value );
234    }
235
236    /**
237     * Resolve a WP user.
238     *
239     * @param WP_User|int|null $user User object or ID.
240     * @return WP_User|null
241     */
242    private static function get_user( $user ) {
243        if ( $user instanceof WP_User ) {
244            return $user;
245        }
246
247        $user = get_userdata( (int) $user );
248        return $user instanceof WP_User ? $user : null;
249    }
250
251    /**
252     * Normalize a scalar value to trimmed plain text.
253     *
254     * @param mixed $value Raw value.
255     * @return string
256     */
257    private static function text( $value ) {
258        if ( ! is_scalar( $value ) ) {
259            return '';
260        }
261        return trim( wp_strip_all_tags( (string) $value ) );
262    }
263
264    /**
265     * Normalize an absolute http(s) URL.
266     *
267     * @param mixed $value Raw value.
268     * @return string
269     */
270    private static function url( $value ) {
271        $list = Schema_Settings::sanitize_url_list( array( $value ) );
272        return $list[0] ?? '';
273    }
274}