Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.62% covered (success)
95.62%
153 / 160
55.56% covered (warning)
55.56%
5 / 9
CRAP
n/a
0 / 0
wp_knowledge_types
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
wp_guideline_scopes
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
wp_guideline_max_length
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wp_knowledge_get_or_create_type_term
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
wp_knowledge_ensure_default_type_term
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
wp_maybe_grant_knowledge_caps
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
11.02
wp_knowledge_maybe_map_term_label
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
wp_guideline_scope_from_slug
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
wp_knowledge_guard_guideline_row
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2/**
3 * Knowledge public API.
4 *
5 * @package automattic/jetpack-knowledge
6 */
7
8if ( ! defined( 'ABSPATH' ) ) {
9    exit;
10}
11
12if ( ! function_exists( 'wp_knowledge_types' ) ) {
13    /**
14     * Returns the registered knowledge types keyed by slug.
15     *
16     * Plugins can register their own types via the `wp_knowledge_types` filter.
17     *
18     * @return array {
19     *     Slug-keyed map of knowledge types.
20     *
21     *     @type array ...$0 {
22     *         Data for a single knowledge type.
23     *
24     *         @type string $title The human-readable label for the type.
25     *     }
26     * }
27     * @phpstan-return array<string, array{title: string}>
28     */
29    function wp_knowledge_types(): array {
30        /**
31         * Filters the knowledge types available on this site.
32         *
33         * @param array $types {
34         *     Slug-keyed map of knowledge types.
35         *
36         *     @type array ...$0 {
37         *         Data for a single knowledge type.
38         *
39         *         @type string $title The human-readable label for the type.
40         *     }
41         * }
42         * @phpstan-param array<string, array{title: string}> $types
43         */
44        return apply_filters(
45            'wp_knowledge_types',
46            array(
47                'guideline' => array(
48                    'title' => _x( 'Guideline', 'knowledge type', 'jetpack-knowledge' ),
49                ),
50                'memory'    => array(
51                    'title' => _x( 'Memory', 'knowledge type', 'jetpack-knowledge' ),
52                ),
53                'note'      => array(
54                    'title' => _x( 'Note', 'knowledge type', 'jetpack-knowledge' ),
55                ),
56            )
57        );
58    }
59}
60
61if ( ! function_exists( 'wp_guideline_scopes' ) ) {
62    /**
63     * Returns the registered guideline scopes keyed by slug.
64     *
65     * @return array {
66     *     Slug-keyed map of guideline scopes.
67     *
68     *     @type array ...$0 {
69     *         Data for a single scope.
70     *
71     *         @type string $title       Human-readable section title.
72     *         @type string $description Human-readable section description.
73     *         @type int    $order       Sort order on the Settings page.
74     *     }
75     * }
76     * @phpstan-return array<string, array{title: string, description: string, order: int}>
77     */
78    function wp_guideline_scopes(): array {
79        /**
80         * Filters the guideline scopes available on this site.
81         *
82         * @param array $scopes Slug-keyed map of guideline scopes.
83         */
84        return apply_filters(
85            'wp_guideline_scopes',
86            array(
87                'site'       => array(
88                    'title'       => __( 'Site', 'jetpack-knowledge' ),
89                    'description' => __( "Describe your site's purpose, goals, and primary audience.", 'jetpack-knowledge' ),
90                    'order'       => 10,
91                ),
92                'copy'       => array(
93                    'title'       => __( 'Copy', 'jetpack-knowledge' ),
94                    'description' => __( 'Set your writing standards for tone, voice, style, and formatting.', 'jetpack-knowledge' ),
95                    'order'       => 20,
96                ),
97                'images'     => array(
98                    'title'       => __( 'Images', 'jetpack-knowledge' ),
99                    'description' => __( 'Outline your style, dimensions, formats, mood and aesthetic preferences.', 'jetpack-knowledge' ),
100                    'order'       => 30,
101                ),
102                'blocks'     => array(
103                    'title'       => __( 'Blocks', 'jetpack-knowledge' ),
104                    'description' => __( 'Create tailored guidelines for specific block types.', 'jetpack-knowledge' ),
105                    'order'       => 40,
106                ),
107                'additional' => array(
108                    'title'       => __( 'Additional', 'jetpack-knowledge' ),
109                    'description' => __( 'Add additional guidelines.', 'jetpack-knowledge' ),
110                    'order'       => 50,
111                ),
112            )
113        );
114    }
115}
116
117if ( ! function_exists( 'wp_guideline_max_length' ) ) {
118    /**
119     * Returns the maximum length, in characters, of a guideline row's content.
120     *
121     * @return int Maximum number of characters allowed in guideline content.
122     */
123    function wp_guideline_max_length(): int {
124        /**
125         * Filters the maximum length, in characters, of a guideline row's content.
126         *
127         * @param int $max_length Maximum number of characters. Default 5000.
128         */
129        return (int) apply_filters( 'wp_guideline_max_length', 5000 );
130    }
131}
132
133if ( ! function_exists( 'wp_knowledge_get_or_create_type_term' ) ) {
134    /**
135     * Resolve a `wp_knowledge_type` term by slug, creating it lazily.
136     *
137     * @access private
138     *
139     * @param string $slug Term slug.
140     * @return int|null Term ID, or null on failure.
141     */
142    function wp_knowledge_get_or_create_type_term( string $slug ): ?int {
143        $term = term_exists( $slug, 'wp_knowledge_type' );
144        if ( $term ) {
145            return (int) $term['term_id'];
146        }
147
148        $switched = switch_to_locale( get_locale() );
149        $term     = wp_insert_term( $slug, 'wp_knowledge_type' );
150        if ( $switched ) {
151            restore_previous_locale();
152        }
153
154        if ( is_wp_error( $term ) ) {
155            // Another request may have created the term after our existence check.
156            $term_id = (int) $term->get_error_data( 'term_exists' );
157            return 'term_exists' === $term->get_error_code() && $term_id > 0 ? $term_id : null;
158        }
159
160        return (int) $term['term_id'];
161    }
162}
163
164if ( ! function_exists( 'wp_knowledge_ensure_default_type_term' ) ) {
165    /**
166     * Hook callback for the `save_post_wp_knowledge` action that assigns the knowledge type term.
167     *
168     * @access private
169     *
170     * @param int $post_id Saved post ID.
171     */
172    function wp_knowledge_ensure_default_type_term( int $post_id ): void {
173        if ( wp_is_post_revision( $post_id ) ) {
174            return;
175        }
176
177        $post = get_post( $post_id );
178        if ( ! $post instanceof WP_Post ) {
179            return;
180        }
181
182        if ( str_starts_with( $post->post_name, 'guideline-' ) ) {
183            $term_id = wp_knowledge_get_or_create_type_term( 'guideline' );
184            if ( null !== $term_id ) {
185                wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' );
186            }
187            return;
188        }
189
190        $terms = get_the_terms( $post_id, 'wp_knowledge_type' );
191        if ( is_wp_error( $terms ) || ! empty( $terms ) ) {
192            return;
193        }
194
195        $term_id = wp_knowledge_get_or_create_type_term( 'note' );
196        if ( null !== $term_id ) {
197            wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' );
198        }
199    }
200}
201
202if ( ! function_exists( 'wp_maybe_grant_knowledge_caps' ) ) {
203    /**
204     * Filters the user capabilities to grant the `wp_knowledge` post type capabilities as necessary.
205     *
206     * @param bool[]   $allcaps An array of all the user's capabilities.
207     * @param string[] $caps    Required primitive capabilities for the requested capability.
208     * @param array    $args    Arguments that accompany the requested capability check.
209     * @param WP_User  $user    The user object.
210     * @return bool[] Filtered array of the user's capabilities.
211     */
212    function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) {
213        if ( ! empty( $allcaps['manage_options'] ) ) {
214            $allcaps['read_knowledge_items']             = true;
215            $allcaps['edit_knowledge_items']             = true;
216            $allcaps['edit_others_knowledge_items']      = true;
217            $allcaps['edit_published_knowledge_items']   = true;
218            $allcaps['edit_private_knowledge_items']     = true;
219            $allcaps['publish_knowledge_items']          = true;
220            $allcaps['delete_knowledge_items']           = true;
221            $allcaps['delete_others_knowledge_items']    = true;
222            $allcaps['delete_published_knowledge_items'] = true;
223            $allcaps['delete_private_knowledge_items']   = true;
224            $allcaps['read_private_knowledge_items']     = true;
225            return $allcaps;
226        }
227
228        if ( empty( $allcaps['edit_posts'] ) ) {
229            return $allcaps;
230        }
231
232        // Ambient floor for Contributor+: `read_knowledge_items` clears the
233        // post-type read check; `edit_knowledge_items` clears the create and
234        // ownership checks that don't pass a post ID. Per-post primitives
235        // are granted only in the per-post branch below.
236        $allcaps['read_knowledge_items'] = true;
237        $allcaps['edit_knowledge_items'] = true;
238
239        if ( ( ! isset( $args[0] ) || ! isset( $args[2] ) ) ) {
240            return $allcaps;
241        }
242
243        if ( ! in_array( $args[0], array( 'edit_post', 'delete_post', 'read_post' ), true ) ) {
244            return $allcaps;
245        }
246
247        $post = get_post( $args[2] );
248        if (
249            ! $post instanceof WP_Post ||
250            'wp_knowledge' !== $post->post_type ||
251            (int) $post->post_author !== (int) $user->ID
252        ) {
253            return $allcaps;
254        }
255
256        /*
257         * A trashed row keeps its pre-trash status in `_wp_trash_meta_status`.
258         */
259        $status = $post->post_status;
260        if ( 'trash' === $status ) {
261            $status = get_post_meta( $post->ID, '_wp_trash_meta_status', true );
262        }
263
264        if ( 'private' !== $status ) {
265            return $allcaps;
266        }
267
268        $allcaps['edit_private_knowledge_items']   = true;
269        $allcaps['delete_knowledge_items']         = true;
270        $allcaps['delete_private_knowledge_items'] = true;
271        $allcaps['read_private_knowledge_items']   = true;
272
273        return $allcaps;
274    }
275}
276
277if ( ! function_exists( 'wp_knowledge_maybe_map_term_label' ) ) {
278    /**
279     * Hook callback for the `wp_insert_term_data` filter that swaps a raw knowledge-type slug for its
280     * human-readable label when WordPress is about to lazily create the term.
281     *
282     * @access private
283     *
284     * @param array  $data     Term data to be inserted (keyed by column name).
285     * @param string $taxonomy Taxonomy slug.
286     * @return array Possibly modified term data.
287     */
288    function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array {
289        if ( 'wp_knowledge_type' !== $taxonomy ) {
290            return $data;
291        }
292
293        if ( $data['name'] !== $data['slug'] ) {
294            return $data;
295        }
296
297        $types = wp_knowledge_types();
298        if ( isset( $types[ $data['slug'] ] ) ) {
299            $data['name'] = $types[ $data['slug'] ]['title'];
300        }
301
302        return $data;
303    }
304}
305
306if ( ! function_exists( 'wp_guideline_scope_from_slug' ) ) {
307    /**
308     * Resolve the scope that owns a guideline row slug.
309     *
310     * @access private
311     *
312     * @param string $slug Post slug.
313     * @return string|null Scope key, or null if the slug is not a registered scope.
314     */
315    function wp_guideline_scope_from_slug( string $slug ): ?string {
316        if ( ! str_starts_with( $slug, 'guideline-' ) ) {
317            return null;
318        }
319
320        $scopes = wp_guideline_scopes();
321        $scope  = substr( $slug, strlen( 'guideline-' ) );
322
323        // A slug that matches a registered scope key is that scope. Checking this
324        // first lets a scope keyed like `block-foo` win over the per-block
325        // namespace below, instead of being swallowed by the blocks scope.
326        if ( isset( $scopes[ $scope ] ) ) {
327            return $scope;
328        }
329
330        // Otherwise a `guideline-block-<name>` row is a per-block row that belongs
331        // to the blocks scope while it is registered. A real block name never
332        // equals a registered scope key, so the check above stays safe.
333        if ( str_starts_with( $slug, 'guideline-block-' ) && strlen( $slug ) > strlen( 'guideline-block-' ) ) {
334            return isset( $scopes['blocks'] ) ? 'blocks' : null;
335        }
336
337        return null;
338    }
339}
340
341if ( ! function_exists( 'wp_knowledge_guard_guideline_row' ) ) {
342    /**
343     * Hook callback for `rest_pre_insert_wp_knowledge` that sanitizes and normalizes guideline rows on the
344     * REST insert path.
345     *
346     * @access private
347     *
348     * @param stdClass        $prepared_post Prepared post object.
349     * @param WP_REST_Request $request       Request object.
350     * @return stdClass Prepared post.
351     */
352    function wp_knowledge_guard_guideline_row( $prepared_post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
353        $slug = '';
354        if ( ! empty( $prepared_post->post_name ) ) {
355            $slug = $prepared_post->post_name;
356        } elseif ( ! empty( $prepared_post->ID ) ) {
357            $existing = get_post( $prepared_post->ID );
358            if ( $existing instanceof WP_Post ) {
359                $slug = $existing->post_name;
360            }
361        }
362
363        // Only shape rows whose slug maps to a registered guideline scope.
364        $scope = wp_guideline_scope_from_slug( (string) $slug );
365        if ( null === $scope ) {
366            return $prepared_post;
367        }
368
369        // Sanitize content: plain text, capped at the guideline length.
370        if ( isset( $prepared_post->post_content ) ) {
371            $content = sanitize_textarea_field( $prepared_post->post_content );
372            $max     = wp_guideline_max_length();
373            if ( mb_strlen( $content, 'UTF-8' ) > $max ) {
374                $content = mb_substr( $content, 0, $max, 'UTF-8' );
375            }
376            $prepared_post->post_content = $content;
377        }
378
379        // Re-stamp single-row registry scope titles in the site locale. The
380        // blocks scope is multi-row, so its per-block rows keep the
381        // client-provided canonical block name.
382        if ( 'blocks' !== $scope ) {
383            $switched = switch_to_locale( get_locale() );
384            $scopes   = wp_guideline_scopes();
385            if ( $switched ) {
386                restore_previous_locale();
387            }
388            if ( isset( $scopes[ $scope ]['title'] ) ) {
389                $prepared_post->post_title = $scopes[ $scope ]['title'];
390            }
391        }
392
393        return $prepared_post;
394    }
395}