1<?php
2/**
3 * WP_Navigation_Fallback class
4 *
5 * Manages fallback behavior for Navigation menus.
6 *
7 * @package WordPress
8 * @subpackage Navigation
9 * @since 6.3.0
10 */
11
12/**
13 * Manages fallback behavior for Navigation menus.
14 *
15 * @since 6.3.0
16 */
17class WP_Navigation_Fallback {
18
19 /**
20 * Updates the wp_navigation custom post type schema, in order to expose
21 * additional fields in the embeddable links of WP_REST_Navigation_Fallback_Controller.
22 *
23 * The Navigation Fallback endpoint may embed the full Navigation Menu object
24 * into the response as the `self` link. By default, the Posts Controller
25 * will only expose a limited subset of fields but the editor requires
26 * additional fields to be available in order to utilize the menu.
27 *
28 * Used with the `rest_wp_navigation_item_schema` hook.
29 *
30 * @since 6.4.0
31 *
32 * @param array $schema The schema for the `wp_navigation` post.
33 * @return array The modified schema.
34 */
35 public static function update_wp_navigation_post_schema( $schema ) {
36 // Expose top level fields.
37 $schema['properties']['status']['context'] = array_merge( $schema['properties']['status']['context'], array( 'embed' ) );
38 $schema['properties']['content']['context'] = array_merge( $schema['properties']['content']['context'], array( 'embed' ) );
39
40 /*
41 * Exposes sub properties of content field.
42 * These sub properties aren't exposed by the posts controller by default,
43 * for requests where context is `embed`.
44 *
45 * @see WP_REST_Posts_Controller::get_item_schema()
46 */
47 $schema['properties']['content']['properties']['raw']['context'] = array_merge( $schema['properties']['content']['properties']['raw']['context'], array( 'embed' ) );
48 $schema['properties']['content']['properties']['rendered']['context'] = array_merge( $schema['properties']['content']['properties']['rendered']['context'], array( 'embed' ) );
49 $schema['properties']['content']['properties']['block_version']['context'] = array_merge( $schema['properties']['content']['properties']['block_version']['context'], array( 'embed' ) );
50
51 /*
52 * Exposes sub properties of title field.
53 * These sub properties aren't exposed by the posts controller by default,
54 * for requests where context is `embed`.
55 *
56 * @see WP_REST_Posts_Controller::get_item_schema()
57 */
58 $schema['properties']['title']['properties']['raw']['context'] = array_merge( $schema['properties']['title']['properties']['raw']['context'], array( 'embed' ) );
59
60 return $schema;
61 }
62
63 /**
64 * Gets (and/or creates) an appropriate fallback Navigation Menu.
65 *
66 * @since 6.3.0
67 *
68 * @return WP_Post|null the fallback Navigation Post or null.
69 */
70 public static function get_fallback() {
71 /**
72 * Filters whether or not a fallback should be created.
73 *
74 * @since 6.3.0
75 *
76 * @param bool $create Whether to create a fallback navigation menu. Default true.
77 */
78 $should_create_fallback = apply_filters( 'wp_navigation_should_create_fallback', true );
79
80 $fallback = static::get_most_recently_published_navigation();
81
82 if ( $fallback || ! $should_create_fallback ) {
83 return $fallback;
84 }
85
86 $fallback = static::create_classic_menu_fallback();
87
88 if ( $fallback && ! is_wp_error( $fallback ) ) {
89 // Return the newly created fallback post object which will now be the most recently created navigation menu.
90 return $fallback instanceof WP_Post ? $fallback : static::get_most_recently_published_navigation();
91 }
92
93 $fallback = static::create_default_fallback();
94
95 if ( $fallback && ! is_wp_error( $fallback ) ) {
96 // Return the newly created fallback post object which will now be the most recently created navigation menu.
97 return $fallback instanceof WP_Post ? $fallback : static::get_most_recently_published_navigation();
98 }
99
100 return null;
101 }
102
103 /**
104 * Finds the most recently published `wp_navigation` post type.
105 *
106 * @since 6.3.0
107 *
108 * @return WP_Post|null the first non-empty Navigation or null.
109 */
110 private static function get_most_recently_published_navigation() {
111
112 $parsed_args = array(
113 'post_type' => 'wp_navigation',
114 'no_found_rows' => true,
115 'update_post_meta_cache' => false,
116 'update_post_term_cache' => false,
117 'order' => 'DESC',
118 'orderby' => 'date',
119 'post_status' => 'publish',
120 'posts_per_page' => 1,
121 );
122
123 $navigation_post = new WP_Query( $parsed_args );
124
125 if ( count( $navigation_post->posts ) > 0 ) {
126 return $navigation_post->posts[0];
127 }
128
129 return null;
130 }
131
132 /**
133 * Creates a Navigation Menu post from a Classic Menu.
134 *
135 * @since 6.3.0
136 *
137 * @return int|WP_Error The post ID of the default fallback menu or a WP_Error object.
138 */
139 private static function create_classic_menu_fallback() {
140 // See if we have a classic menu.
141 $classic_nav_menu = static::get_fallback_classic_menu();
142
143 if ( ! $classic_nav_menu ) {
144 return new WP_Error( 'no_classic_menus', __( 'No Classic Menus found.' ) );
145 }
146
147 // If there is a classic menu then convert it to blocks.
148 $classic_nav_menu_blocks = WP_Classic_To_Block_Menu_Converter::convert( $classic_nav_menu );
149
150 if ( is_wp_error( $classic_nav_menu_blocks ) ) {
151 return $classic_nav_menu_blocks;
152 }
153
154 if ( empty( $classic_nav_menu_blocks ) ) {
155 return new WP_Error( 'cannot_convert_classic_menu', __( 'Unable to convert Classic Menu to blocks.' ) );
156 }
157
158 // Create a new navigation menu from the classic menu.
159 $classic_menu_fallback = wp_insert_post(
160 array(
161 'post_content' => $classic_nav_menu_blocks,
162 'post_title' => $classic_nav_menu->name,
163 'post_name' => $classic_nav_menu->slug,
164 'post_status' => 'publish',
165 'post_type' => 'wp_navigation',
166 ),
167 true // So that we can check whether the result is an error.
168 );
169
170 return $classic_menu_fallback;
171 }
172
173 /**
174 * Determines the most appropriate classic navigation menu to use as a fallback.
175 *
176 * @since 6.3.0
177 *
178 * @return WP_Term|null The most appropriate classic navigation menu to use as a fallback.
179 */
180 private static function get_fallback_classic_menu() {
181 $classic_nav_menus = wp_get_nav_menus();
182
183 if ( ! $classic_nav_menus || is_wp_error( $classic_nav_menus ) ) {
184 return null;
185 }
186
187 $nav_menu = static::get_nav_menu_at_primary_location();
188
189 if ( $nav_menu ) {
190 return $nav_menu;
191 }
192
193 $nav_menu = static::get_nav_menu_with_primary_slug( $classic_nav_menus );
194
195 if ( $nav_menu ) {
196 return $nav_menu;
197 }
198
199 return static::get_most_recently_created_nav_menu( $classic_nav_menus );
200 }
201
202
203 /**
204 * Sorts the classic menus and returns the most recently created one.
205 *
206 * @since 6.3.0
207 *
208 * @param WP_Term[] $classic_nav_menus Array of classic nav menu term objects.
209 * @return WP_Term The most recently created classic nav menu.
210 */
211 private static function get_most_recently_created_nav_menu( $classic_nav_menus ) {
212 usort(
213 $classic_nav_menus,
214 static function ( $a, $b ) {
215 return $b->term_id - $a->term_id;
216 }
217 );
218
219 return $classic_nav_menus[0];
220 }
221
222 /**
223 * Returns the classic menu with the slug `primary` if it exists.
224 *
225 * @since 6.3.0
226 *
227 * @param WP_Term[] $classic_nav_menus Array of classic nav menu term objects.
228 * @return WP_Term|null The classic nav menu with the slug `primary` or null.
229 */
230 private static function get_nav_menu_with_primary_slug( $classic_nav_menus ) {
231 foreach ( $classic_nav_menus as $classic_nav_menu ) {
232 if ( 'primary' === $classic_nav_menu->slug ) {
233 return $classic_nav_menu;
234 }
235 }
236
237 return null;
238 }
239
240
241 /**
242 * Gets the classic menu assigned to the `primary` navigation menu location
243 * if it exists.
244 *
245 * @since 6.3.0
246 *
247 * @return WP_Term|null The classic nav menu assigned to the `primary` location or null.
248 */
249 private static function get_nav_menu_at_primary_location() {
250 $locations = get_nav_menu_locations();
251
252 if ( isset( $locations['primary'] ) ) {
253 $primary_menu = wp_get_nav_menu_object( $locations['primary'] );
254
255 if ( $primary_menu ) {
256 return $primary_menu;
257 }
258 }
259
260 return null;
261 }
262
263 /**
264 * Creates a default Navigation Block Menu fallback.
265 *
266 * @since 6.3.0
267 *
268 * @return int|WP_Error The post ID of the default fallback menu or a WP_Error object.
269 */
270 private static function create_default_fallback() {
271
272 $default_blocks = static::get_default_fallback_blocks();
273
274 // Create a new navigation menu from the fallback blocks.
275 $default_fallback = wp_insert_post(
276 array(
277 'post_content' => $default_blocks,
278 'post_title' => _x( 'Navigation', 'Title of a Navigation menu' ),
279 'post_name' => 'navigation',
280 'post_status' => 'publish',
281 'post_type' => 'wp_navigation',
282 ),
283 true // So that we can check whether the result is an error.
284 );
285
286 return $default_fallback;
287 }
288
289 /**
290 * Gets the rendered markup for the default fallback blocks.
291 *
292 * @since 6.3.0
293 *
294 * @return string default blocks markup to use a the fallback.
295 */
296 private static function get_default_fallback_blocks() {
297 $registry = WP_Block_Type_Registry::get_instance();
298
299 // If `core/page-list` is not registered then use empty blocks.
300 return $registry->is_registered( 'core/page-list' ) ? '<!-- wp:page-list /-->' : '';
301 }
302}
303