Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.85% covered (warning)
65.85%
54 / 82
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_SEO_Admin_Columns
65.85% covered (warning)
65.85%
54 / 82
33.33% covered (danger)
33.33%
3 / 9
76.36
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 default_hidden_columns
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 backfill_hidden_columns
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 register_columns_for_post_types
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_supported_post_types
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
4.31
 add_columns
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 render_column
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 schema_type_label
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 enqueue_assets
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Adds factual SEO columns to wp-admin post list tables.
4 *
5 * Surfaces the per-post SEO *state* at a glance — schema type, whether a meta
6 * description is set, and search visibility — without grading it. Whether a
7 * given setting should be configured depends on the post's purpose, so we
8 * report facts and let the author decide.
9 *
10 * @package automattic/jetpack
11 */
12
13/**
14 * Registers read-only SEO columns on every public post-list table.
15 */
16class Jetpack_SEO_Admin_Columns {
17
18    /**
19     * The column IDs this class registers on the post-list tables.
20     *
21     * @var string[]
22     */
23    const COLUMNS = array( 'jetpack_seo_schema', 'jetpack_seo_description', 'jetpack_seo_search' );
24
25    /**
26     * User meta listing the post-list screen IDs whose saved hidden-column set
27     * has already been backfilled with the SEO columns.
28     */
29    const BACKFILL_USER_META_KEY = 'jetpack_seo_columns_backfilled_screens';
30
31    /**
32     * Wire all hooks.
33     *
34     * @return void
35     */
36    public static function init() {
37        add_action( 'admin_init', array( __CLASS__, 'register_columns_for_post_types' ) );
38        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
39        add_filter( 'default_hidden_columns', array( __CLASS__, 'default_hidden_columns' ), 10, 2 );
40        add_action( 'current_screen', array( __CLASS__, 'backfill_hidden_columns' ) );
41    }
42
43    /**
44     * Hide the SEO columns by default in Screen Options.
45     *
46     * Three extra always-on columns squeeze the title column unreadably narrow,
47     * so we default them to hidden using core's `default_hidden_columns` filter —
48     * the standard mechanism for choosing which columns start hidden in Screen
49     * Options. It only reaches users who have never customized Screen Options for
50     * the screen; everyone else is handled by self::backfill_hidden_columns().
51     *
52     * @param string[]  $hidden Column IDs hidden by default.
53     * @param WP_Screen $screen Current screen.
54     * @return string[]
55     */
56    public static function default_hidden_columns( $hidden, $screen ) {
57        if ( isset( $screen->base ) && 'edit' === $screen->base ) {
58            $hidden = array_merge( $hidden, self::COLUMNS );
59        }
60        return $hidden;
61    }
62
63    /**
64     * Hide the SEO columns for users whose Screen Options predate them.
65     *
66     * `default_hidden_columns` is a first-run default: core only consults it when
67     * the user has no saved hidden-column set for the screen (`$use_defaults =
68     * ! is_array( $hidden )` in wp-admin/includes/screen.php). Toggling any single
69     * column checkbox writes that user meta, so a user who customized Screen
70     * Options at any point — years before these columns existed — never gets the
71     * default and sees all three columns crowding the title column.
72     *
73     * Backfill those users once per screen by merging the SEO columns into the set
74     * they already have, the way core seeds its own nav-menu column defaults in
75     * wp-admin/includes/nav-menu.php. Screens are recorded as they're visited, so a
76     * post type registered later still gets handled the first time it's opened, and
77     * a user who turns the columns back on afterwards keeps them.
78     *
79     * @param WP_Screen $screen Current screen.
80     * @return void
81     */
82    public static function backfill_hidden_columns( $screen ) {
83        if ( ! ( $screen instanceof WP_Screen ) || 'edit' !== $screen->base ) {
84            return;
85        }
86
87        $user_id = get_current_user_id();
88        if ( ! $user_id ) {
89            return;
90        }
91
92        $backfilled = get_user_meta( $user_id, self::BACKFILL_USER_META_KEY, true );
93        $backfilled = is_array( $backfilled ) ? $backfilled : array();
94        if ( in_array( $screen->id, $backfilled, true ) ) {
95            return;
96        }
97
98        $option_name = 'manage' . $screen->id . 'columnshidden';
99        $hidden      = get_user_option( $option_name, $user_id );
100
101        /*
102         * A non-array means the user has never customized this screen, so
103         * `default_hidden_columns` already hides the columns for them and there is
104         * nothing to backfill — just record the screen so we stop looking.
105         */
106        if ( is_array( $hidden ) ) {
107            $missing = array_values( array_diff( self::COLUMNS, $hidden ) );
108            if ( ! empty( $missing ) ) {
109                /*
110                 * $is_global = true writes the unprefixed key — the same one core's
111                 * `hidden-columns` AJAX handler writes and that the get_user_option()
112                 * read above falls back to. Passing false would write a blog-prefixed
113                 * key that core itself never updates, so the two would diverge.
114                 */
115                update_user_option( $user_id, $option_name, array_values( array_merge( $hidden, $missing ) ), true );
116            }
117        }
118
119        $backfilled[] = $screen->id;
120        update_user_meta( $user_id, self::BACKFILL_USER_META_KEY, $backfilled );
121    }
122
123    /**
124     * Register columns + renderers for each supported post type.
125     *
126     * @return void
127     */
128    public static function register_columns_for_post_types() {
129        foreach ( self::get_supported_post_types() as $post_type ) {
130            add_filter( "manage_{$post_type}_posts_columns", array( __CLASS__, 'add_columns' ) );
131            add_action( "manage_{$post_type}_posts_custom_column", array( __CLASS__, 'render_column' ), 10, 2 );
132        }
133    }
134
135    /**
136     * Post types that get the SEO columns.
137     *
138     * Defers to the SEO package so the columns cover exactly the post types the
139     * Jetpack > SEO Content tab lists, rather than a second copy of the same query
140     * that can drift from it. The guard covers older bundled snapshots of the
141     * package that predate Post_Types.
142     *
143     * @return string[]
144     */
145    private static function get_supported_post_types() {
146        if ( method_exists( '\Automattic\Jetpack\SEO\Post_Types', 'get_supported_content_types' ) ) {
147            return \Automattic\Jetpack\SEO\Post_Types::get_supported_content_types();
148        }
149
150        $post_types = get_post_types(
151            array(
152                'public'       => true,
153                'show_ui'      => true,
154                'show_in_rest' => true,
155            ),
156            'names'
157        );
158        unset( $post_types['attachment'] );
159
160        return array_values( $post_types );
161    }
162
163    /**
164     * Insert the SEO columns just after the title column.
165     *
166     * @param array $columns Existing columns keyed by column name.
167     * @return array
168     */
169    public static function add_columns( $columns ) {
170        $new = array();
171        foreach ( $columns as $key => $label ) {
172            $new[ $key ] = $label;
173            if ( 'title' === $key ) {
174                $new['jetpack_seo_schema']      = __( 'Schema', 'jetpack' );
175                $new['jetpack_seo_description'] = __( 'Meta description', 'jetpack' );
176                $new['jetpack_seo_search']      = __( 'Search', 'jetpack' );
177            }
178        }
179        return $new;
180    }
181
182    /**
183     * Render a single cell — factual state only.
184     *
185     * @param string $column  Column identifier.
186     * @param int    $post_id Current row post ID.
187     * @return void
188     */
189    public static function render_column( $column, $post_id ) {
190        if ( ! in_array( $column, self::COLUMNS, true ) ) {
191            return;
192        }
193
194        $coverage = Jetpack_SEO_Posts::get_post_seo_coverage( $post_id );
195
196        switch ( $column ) {
197            case 'jetpack_seo_schema':
198                $schema = Jetpack_SEO_Posts::get_post_schema_type( $post_id );
199                echo esc_html( '' !== $schema ? self::schema_type_label( $schema ) : '—' );
200                break;
201
202            case 'jetpack_seo_description':
203                // wp_kses_post() sanitizes the markup and signals the escaping to PHPCS;
204                // the muted branch wraps its (already-escaped) label in a <span>.
205                echo wp_kses_post(
206                    $coverage['has_description']
207                        ? esc_html__( 'Set', 'jetpack' )
208                        : '<span class="jetpack-seo-col-muted">' . esc_html__( 'Not set', 'jetpack' ) . '</span>'
209                );
210                break;
211
212            case 'jetpack_seo_search':
213                echo wp_kses_post(
214                    $coverage['noindex']
215                        ? esc_html__( 'Hidden', 'jetpack' )
216                        : '<span class="jetpack-seo-col-muted">' . esc_html__( 'Visible', 'jetpack' ) . '</span>'
217                );
218                break;
219        }
220    }
221
222    /**
223     * Display label for an allowed schema type.
224     *
225     * @param string $schema Schema type slug.
226     * @return string
227     */
228    private static function schema_type_label( $schema ) {
229        switch ( $schema ) {
230            case 'article':
231                return __( 'Article', 'jetpack' );
232            case 'faq':
233                return __( 'FAQ', 'jetpack' );
234            default:
235                return ucfirst( $schema );
236        }
237    }
238
239    /**
240     * Minimal column-width styling on edit.php only (no color-coding —
241     * these columns report state, not a grade).
242     *
243     * @param string $hook_suffix Current admin hook suffix.
244     * @return void
245     */
246    public static function enqueue_assets( $hook_suffix ) {
247        if ( 'edit.php' !== $hook_suffix ) {
248            return;
249        }
250        wp_register_style( 'jetpack-seo-admin-columns', false, array(), JETPACK__VERSION );
251        wp_add_inline_style(
252            'jetpack-seo-admin-columns',
253            '.column-jetpack_seo_schema,.column-jetpack_seo_description,.column-jetpack_seo_search{width:9em}' .
254            '.jetpack-seo-col-muted{color:#787c82}'
255        );
256        wp_enqueue_style( 'jetpack-seo-admin-columns' );
257    }
258}