• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

timber / timber / 5690057835

pending completion
5690057835

push

github

nlemoine
Merge branch '2.x' of github.com:timber/timber into 2.x-refactor-file-models

# Conflicts:
#	src/Attachment.php
#	src/ExternalImage.php
#	src/FileSize.php
#	src/URLHelper.php

1134 of 1134 new or added lines in 55 files covered. (100.0%)

3923 of 4430 relevant lines covered (88.56%)

59.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.44
/src/Menu.php
1
<?php
2

3
namespace Timber;
4

5
use Throwable;
6

7
use Timber\Factory\MenuItemFactory;
8
use WP_Term;
9

10
/**
11
 * Class Menu
12
 *
13
 * @api
14
 */
15
class Menu extends CoreEntity
16
{
17
    /**
18
     * The underlying WordPress Core object.
19
     *
20
     * @since 2.0.0
21
     *
22
     * @var WP_Term|null
23
     */
24
    protected ?WP_Term $wp_object;
25

26
    public $object_type = 'term';
27

28
    /**
29
     * @api
30
     * @var integer The depth of the menu we are rendering
31
     */
32
    public $depth;
33

34
    /**
35
     * @api
36
     * @var array|null Array of `Timber\Menu` objects you can to iterate through.
37
     */
38
    public $items = null;
39

40
    /**
41
     * @api
42
     * @var int The ID of the menu, corresponding to the wp_terms table.
43
     */
44
    public $id;
45

46
    /**
47
     * @api
48
     * @var int The ID of the menu, corresponding to the wp_terms table.
49
     */
50
    public $ID;
51

52
    /**
53
     * @api
54
     * @var int The ID of the menu, corresponding to the wp_terms table.
55
     */
56
    public $term_id;
57

58
    /**
59
     * @api
60
     * @var string The name of the menu (ex: `Main Navigation`).
61
     */
62
    public $name;
63

64
    /**
65
     * Menu slug.
66
     *
67
     * @api
68
     * @var string The menu slug.
69
     */
70
    public $slug;
71

72
    /**
73
     * @api
74
     * @var string The name of the menu (ex: `Main Navigation`).
75
     */
76
    public $title;
77

78
    /**
79
     * Menu args.
80
     *
81
     * @api
82
     * @since 1.9.6
83
     * @var object An object of menu args.
84
     */
85
    public $args;
86

87
    /**
88
     * @var MenuItem the current menu item
89
     */
90
    private $_current_item;
91

92
    /**
93
     * @api
94
     * @var array The unfiltered args sent forward via the user in the __construct
95
     */
96
    public $raw_args;
97

98
    /**
99
     * Theme Location.
100
     *
101
     * @api
102
     * @since 1.9.6
103
     * @var string The theme location of the menu, if available.
104
     */
105
    public $theme_location = null;
106

107
    /**
108
     * Sorted menu items.
109
     *
110
     * @var array
111
     */
112
    protected $sorted_menu_items = [];
113

114
    /**
115
     * @internal
116
     * @param WP_Term   $menu The vanilla WordPress term object to build from.
117
     * @param array      $args Optional. Right now, only the `depth` is
118
     *                            supported which says how many levels of hierarchy should be
119
     *                            included in the menu. Default `0`, which is all levels.
120
     * @return \Timber\Menu
121
     */
122
    public static function build(?WP_Term $menu, $args = []): ?self
123
    {
124
        /**
125
         * Default arguments from wp_nav_menu() function.
126
         *
127
         * @see wp_nav_menu()
128
         */
129
        $defaults = [
64✔
130
            'menu' => '',
64✔
131
            'container' => 'div',
64✔
132
            'container_class' => '',
64✔
133
            'container_id' => '',
64✔
134
            'container_aria_label' => '',
64✔
135
            'menu_class' => 'menu',
64✔
136
            'menu_id' => '',
64✔
137
            'echo' => true,
64✔
138
            'fallback_cb' => 'wp_page_menu',
64✔
139
            'before' => '',
64✔
140
            'after' => '',
64✔
141
            'link_before' => '',
64✔
142
            'link_after' => '',
64✔
143
            'items_wrap' => '<ul id="%1$s" class="%2$s">%3$s</ul>',
64✔
144
            'item_spacing' => 'preserve',
64✔
145
            'depth' => 0,
64✔
146
            'walker' => '',
64✔
147
            'theme_location' => '',
64✔
148
        ];
64✔
149

150
        $args = \wp_parse_args($args, $defaults);
64✔
151

152
        if (!\in_array($args['item_spacing'], ['preserve', 'discard'], true)) {
64✔
153
            // Invalid value, fall back to default.
154
            $args['item_spacing'] = $defaults['item_spacing'];
×
155
        }
156

157
        /**
158
         * @see wp_nav_menu()
159
         */
160
        $args = \apply_filters('wp_nav_menu_args', $args);
64✔
161
        $args = (object) $args;
64✔
162

163
        /**
164
         * Since Timber doesn't use HTML here, try to unserialize the maybe cached menu object
165
         *
166
         * @see wp_nav_menu()
167
         */
168
        $nav_menu = \apply_filters('pre_wp_nav_menu', null, $args);
64✔
169
        if (null !== $nav_menu) {
64✔
170
            try {
171
                $nav_menu = \unserialize($nav_menu);
1✔
172
                if ($nav_menu instanceof Menu) {
1✔
173
                    return $nav_menu;
1✔
174
                }
175
            } catch (Throwable $e) {
×
176
            }
177
        }
178

179
        /**
180
         * No valid menu term provided.
181
         *
182
         * In earlier versions, Timber returned a pages menu if no menu was found. Now, it returns
183
         * null. If you still need the pages menu, you can use Timber\Timber::get_pages_menu().
184
         *
185
         * @see \Timber\Timber::get_pages_menu()
186
         */
187
        if (!$menu) {
64✔
188
            return null;
×
189
        }
190

191
        // Skip the menu term guessing part, we already have our menu term
192

193
        $menu_items = \wp_get_nav_menu_items($menu->term_id, [
64✔
194
            'update_post_term_cache' => false,
64✔
195
        ]);
64✔
196

197
        \_wp_menu_item_classes_by_context($menu_items);
64✔
198

199
        $sorted_menu_items = [];
64✔
200
        $menu_items_with_children = [];
64✔
201
        foreach ((array) $menu_items as $menu_item) {
64✔
202
            $sorted_menu_items[$menu_item->menu_order] = $menu_item;
55✔
203
            if ($menu_item->menu_item_parent) {
55✔
204
                $menu_items_with_children[$menu_item->menu_item_parent] = true;
38✔
205
            }
206
        }
207

208
        // Add the menu-item-has-children class where applicable.
209
        if ($menu_items_with_children) {
64✔
210
            foreach ($sorted_menu_items as &$menu_item) {
38✔
211
                if (isset($menu_items_with_children[$menu_item->ID])) {
38✔
212
                    $menu_item->classes[] = 'menu-item-has-children';
38✔
213
                }
214
            }
215
        }
216

217
        unset($menu_items, $menu_item);
64✔
218

219
        /**
220
         * @see wp_nav_menu()
221
         */
222
        $sorted_menu_items = \apply_filters('wp_nav_menu_objects', $sorted_menu_items, $args);
64✔
223

224
        /**
225
         * Filters the sorted list of menu item objects before creating the Menu object.
226
         *
227
         * @since 2.0.0
228
         * @example
229
         * ```
230
         * add_filter( 'timber/menu/item_objects', function ( $items ) {
231
         *     return array_map(function ($item) {
232
         *         if ( is_object( $item ) && ! ( $item instanceof \WP_Post ) ) {
233
         *             return new \WP_Post( get_object_vars( $item ) );
234
         *         }
235
         *
236
         *         return $item;
237
         *     }, $items);
238
         * } );
239
         * ```
240
         *
241
         * @param array<mixed> $item
242
         * @param WP_Term $menu
243
         */
244
        $sorted_menu_items = \apply_filters('timber/menu/item_objects', $sorted_menu_items, $menu);
64✔
245

246
        // Create Menu object
247
        $nav_menu = new static($menu, (array) $args);
64✔
248
        $nav_menu->sorted_menu_items = $sorted_menu_items;
64✔
249

250
        // Convert items into MenuItem objects
251
        $sorted_menu_items = $nav_menu->convert_menu_items($sorted_menu_items);
64✔
252
        $sorted_menu_items = $nav_menu->order_children($sorted_menu_items);
64✔
253
        $sorted_menu_items = $nav_menu->strip_to_depth_limit($sorted_menu_items);
64✔
254

255
        $nav_menu->items = $sorted_menu_items;
64✔
256
        unset($sorted_menu_items);
64✔
257

258
        /**
259
         * Since Timber doesn't use HTML, serialize the menu object to provide a cacheable string
260
         *
261
         * @see wp_nav_menu()
262
         */
263
        $_nav_menu = \apply_filters('wp_nav_menu', \serialize($nav_menu), $args);
64✔
264

265
        return $nav_menu;
64✔
266
    }
267

268
    /**
269
     * Initialize a menu.
270
     *
271
     * @api
272
     *
273
     * @param WP_Term|null $term A menu slug, the term ID of the menu, the full name from the admin
274
     *                            menu, the slug of the registered location or nothing. Passing
275
     *                            nothing is good if you only have one menu. Timber will grab what
276
     *                            it finds.
277
     * @param array $args         Optional. Right now, only the `depth` is supported which says how
278
     *                            many levels of hierarchy should be included in the menu. Default
279
     *                            `0`, which is all levels.
280
     */
281
    final protected function __construct(?WP_term $term, array $args = [])
282
    {
283
        // For future enhancements?
284
        $this->raw_args = $args;
72✔
285
        $this->args = (object) $args;
72✔
286

287
        if (isset($this->args->depth)) {
72✔
288
            $this->depth = (int) $this->args->depth;
72✔
289
        }
290

291
        if (!$term) {
72✔
292
            return;
10✔
293
        }
294

295
        // Set theme location if available
296
        $locations = \array_flip(\array_filter(\get_nav_menu_locations(), fn ($location) => \is_string($location) || \is_int($location)));
64✔
297

298
        $this->theme_location = $locations[$term->term_id] ?? null;
64✔
299

300
        if ($this->theme_location) {
64✔
301
            $this->args->theme_location = $this->theme_location;
9✔
302
        }
303

304
        $this->import($term);
64✔
305
        $this->ID = $this->term_id;
64✔
306
        $this->id = $this->term_id;
64✔
307
        $this->wp_object = $term;
64✔
308
        $this->title = $this->name;
64✔
309
    }
310

311
    /**
312
     * Gets the underlying WordPress Core object.
313
     *
314
     * @since 2.0.0
315
     *
316
     * @return WP_Term|null
317
     */
318
    public function wp_object(): ?WP_Term
319
    {
320
        return $this->wp_object;
1✔
321
    }
322

323
    /**
324
     * Convert menu items into MenuItem objects
325
     *
326
     * @param array $menu_items
327
     * @return MenuItem[]
328
     */
329
    protected function convert_menu_items(array $menu_items): array
330
    {
331
        $menu_item_factory = new MenuItemFactory();
70✔
332
        return \array_map(function ($item) use ($menu_item_factory): MenuItem {
70✔
333
            return $menu_item_factory->from($item, $this);
61✔
334
        }, $menu_items);
70✔
335
    }
336

337
    /**
338
     * Find a parent menu item in a set of menu items.
339
     *
340
     * @api
341
     * @param array $menu_items An array of menu items.
342
     * @param int   $parent_id  The parent ID to look for.
343
     * @return \Timber\MenuItem|null A menu item. False if no parent was found.
344
     */
345
    public function find_parent_item_in_menu(array $menu_items, int $parent_id): ?MenuItem
346
    {
347
        foreach ($menu_items as $item) {
×
348
            if ($item->ID === $parent_id) {
×
349
                return $item;
×
350
            }
351
        }
352
        return null;
×
353
    }
354

355
    /**
356
     * @internal
357
     * @param array $items
358
     * @return MenuItem[]
359
     */
360
    protected function order_children(array $items): array
361
    {
362
        $items_by_id = [];
70✔
363
        $menu_items = [];
70✔
364

365
        foreach ($items as $item) {
70✔
366
            // Index each item by its ID
367
            $items_by_id[$item->ID] = $item;
61✔
368
        }
369

370
        // Walk through our indexed items and assign them to their parents as applicable
371
        foreach ($items_by_id as $item) {
70✔
372
            if (!empty($item->menu_item_parent) && isset($items_by_id[$item->menu_item_parent])) {
61✔
373
                // This one is a child item, add it to its parent
374
                $items_by_id[$item->menu_item_parent]->add_child($item);
40✔
375
            } else {
376
                // This is a top-level item, add it as such
377
                $menu_items[] = $item;
61✔
378
            }
379
        }
380
        return $menu_items;
70✔
381
    }
382

383
    /**
384
     * @internal
385
     * @param array $menu_items
386
     */
387
    protected function strip_to_depth_limit(array $menu_items, int $current = 1): array
388
    {
389
        $depth = (int) $this->depth; // Confirms still int.
70✔
390
        if ($depth <= 0) {
70✔
391
            return $menu_items;
67✔
392
        }
393

394
        foreach ($menu_items as &$current_item) {
6✔
395
            if ($current === $depth) {
6✔
396
                $current_item->remove_class('menu-item-has-children');
6✔
397
                $current_item->children = false;
6✔
398
                continue;
6✔
399
            }
400

401
            $current_item->children = $this->strip_to_depth_limit($current_item->children, $current + 1);
2✔
402
        }
403

404
        return $menu_items;
6✔
405
    }
406

407
    /**
408
     * Gets a menu meta value.
409
     *
410
     * @api
411
     * @deprecated 2.0.0, use `{{ menu.meta('field_name') }}` instead.
412
     * @see \Timber\Menu::meta()
413
     *
414
     * @param string $field_name The field name for which you want to get the value.
415
     * @return mixed The meta field value.
416
     */
417
    public function get_field($field_name = null)
418
    {
419
        Helper::deprecated(
×
420
            "{{ menu.get_field('field_name') }}",
×
421
            "{{ menu.meta('field_name') }}",
×
422
            '2.0.0'
×
423
        );
×
424

425
        return $this->meta($field_name);
×
426
    }
427

428
    /**
429
     * Get menu items.
430
     *
431
     * Instead of using this function, you can use the `$items` property directly to get the items
432
     * for a menu.
433
     *
434
     * @api
435
     * @example
436
     * ```twig
437
     * {% for item in menu.get_items %}
438
     *     <a href="{{ item.link }}">{{ item.title }}</a>
439
     * {% endfor %}
440
     * ```
441
     * @return array Array of `Timber\MenuItem` objects. Empty array if no items could be found.
442
     */
443
    public function get_items()
444
    {
445
        if (\is_array($this->items)) {
24✔
446
            return $this->items;
24✔
447
        }
448

449
        return [];
×
450
    }
451

452
    /**
453
     * Get the current MenuItem based on the WP context
454
     *
455
     * @see _wp_menu_item_classes_by_context()
456
     * @example
457
     * Say you want to render the sub-tree of the main menu that corresponds
458
     * to the menu item for the current page, such as in a context-aware sidebar:
459
     * ```twig
460
     * <div class="sidebar">
461
     *   <a href="{{ menu.current_item.link }}">
462
     *     {{ menu.current_item.title }}
463
     *   </a>
464
     *   <ul>
465
     *     {% for child in menu.current_item.children %}
466
     *       <li>
467
     *         <a href="{{ child.link }}">{{ child.title }}</a>
468
     *       </li>
469
     *     {% endfor %}
470
     *   </ul>
471
     * </div>
472
     * ```
473
     * @param int $depth the maximum depth to traverse the menu tree to find the
474
     * current item. Defaults to null, meaning no maximum. 1-based, meaning the
475
     * top level is 1.
476
     * @return MenuItem the current `Timber\MenuItem` object, i.e. the menu item
477
     * corresponding to the current post.
478
     */
479
    public function current_item($depth = null)
480
    {
481
        if (false === $this->_current_item) {
8✔
482
            // I TOLD YOU BEFORE.
483
            return false;
×
484
        }
485

486
        if (empty($this->items)) {
8✔
487
            $this->_current_item = false;
1✔
488
            return $this->_current_item;
1✔
489
        }
490

491
        if (!isset($this->_current_item)) {
7✔
492
            $current = $this->traverse_items_for_current(
7✔
493
                $this->items,
7✔
494
                $depth
7✔
495
            );
7✔
496

497
            if (\is_null($depth)) {
7✔
498
                $this->_current_item = $current;
5✔
499
            } else {
500
                return $current;
3✔
501
            }
502
        }
503

504
        return $this->_current_item;
5✔
505
    }
506

507
    /**
508
     * Alias for current_top_level_item(1).
509
     *
510
     * @return MenuItem the current top-level `Timber\MenuItem` object.
511
     */
512
    public function current_top_level_item()
513
    {
514
        return $this->current_item(1);
1✔
515
    }
516

517
    /**
518
     * Traverse an array of MenuItems in search of the current item.
519
     *
520
     * @internal
521
     * @param array $items the items to traverse.
522
     */
523
    private function traverse_items_for_current($items, $depth)
524
    {
525
        $current = false;
7✔
526
        $currentDepth = 1;
7✔
527
        $i = 0;
7✔
528

529
        while (isset($items[$i])) {
7✔
530
            $item = $items[$i];
7✔
531

532
            if ($item->current) {
7✔
533
                // cache this item for subsequent calls.
534
                $current = $item;
3✔
535
                // stop looking.
536
                break;
3✔
537
            } elseif ($item->current_item_ancestor) {
7✔
538
                // we found an ancestor,
539
                // but keep looking for a more precise match.
540
                $current = $item;
6✔
541

542
                if ($currentDepth === $depth) {
6✔
543
                    // we're at max traversal depth.
544
                    return $current;
3✔
545
                }
546

547
                // we're in the right subtree, so go deeper.
548
                if ($item->children()) {
5✔
549
                    // reset the counter, since we're at a new level.
550
                    $items = $item->children();
3✔
551
                    $i = 0;
3✔
552
                    $currentDepth++;
3✔
553
                    continue;
3✔
554
                }
555
            }
556

557
            $i++;
4✔
558
        }
559

560
        return $current;
5✔
561
    }
562

563
    public function __toString()
564
    {
565
        static $menu_id_slugs = [];
1✔
566

567
        $args = $this->args;
1✔
568

569
        $items = '';
1✔
570
        $nav_menu = '';
1✔
571
        $show_container = false;
1✔
572

573
        if ($args->container) {
1✔
574
            /**
575
            * Filters the list of HTML tags that are valid for use as menu containers.
576
            *
577
            * @since 3.0.0
578
            *
579
            * @param string[] $tags The acceptable HTML tags for use as menu containers.
580
            *                       Default is array containing 'div' and 'nav'.
581
            */
582
            $allowed_tags = \apply_filters('wp_nav_menu_container_allowedtags', ['div', 'nav']);
1✔
583

584
            if (\is_string($args->container) && \in_array($args->container, $allowed_tags, true)) {
1✔
585
                $show_container = true;
1✔
586
                $class = $args->container_class ? ' class="' . \esc_attr($args->container_class) . '"' : ' class="menu-' . $this->slug . '-container"';
1✔
587
                $id = $args->container_id ? ' id="' . \esc_attr($args->container_id) . '"' : '';
1✔
588
                $aria_label = ('nav' === $args->container && $args->container_aria_label) ? ' aria-label="' . \esc_attr($args->container_aria_label) . '"' : '';
1✔
589
                $nav_menu .= '<' . $args->container . $id . $class . $aria_label . '>';
1✔
590
            }
591
        }
592

593
        $items .= \walk_nav_menu_tree($this->sorted_menu_items, $args->depth, $args);
1✔
594

595
        // Attributes.
596
        if (!empty($args->menu_id)) {
1✔
597
            $wrap_id = $args->menu_id;
1✔
598
        } else {
599
            $wrap_id = 'menu-' . $this->slug;
×
600

601
            while (\in_array($wrap_id, $menu_id_slugs, true)) {
×
602
                if (\preg_match('#-(\d+)$#', $wrap_id, $matches)) {
×
603
                    $wrap_id = \preg_replace('#-(\d+)$#', '-' . ++$matches[1], $wrap_id);
×
604
                } else {
605
                    $wrap_id = $wrap_id . '-1';
×
606
                }
607
            }
608
        }
609
        $menu_id_slugs[] = $wrap_id;
1✔
610

611
        $wrap_class = $args->menu_class ? $args->menu_class : '';
1✔
612

613
        $nav_menu .= \sprintf($args->items_wrap, \esc_attr($wrap_id), \esc_attr($wrap_class), $items);
1✔
614
        if ($show_container) {
1✔
615
            $nav_menu .= '</' . $args->container . '>';
1✔
616
        }
617

618
        return $nav_menu;
1✔
619
    }
620

621
    /**
622
     * Checks whether the current user can edit the menu.
623
     *
624
     * @api
625
     * @since 2.0.0
626
     * @return bool
627
     */
628
    public function can_edit(): bool
629
    {
630
        return \current_user_can('edit_theme_options');
1✔
631
    }
632
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc