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

timber / timber / 20695674007

04 Jan 2026 04:14PM UTC coverage: 89.681% (+1.5%) from 88.211%
20695674007

push

travis-ci

nlemoine
test: Fix ancestors post tests

4615 of 5146 relevant lines covered (89.68%)

63.45 hits per line

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

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

3
namespace Timber;
4

5
use Stringable;
6
use Throwable;
7
use Timber\Factory\MenuItemFactory;
8
use WP_Term;
9

10
/**
11
 * Class Menu
12
 *
13
 * @api
14
 */
15
class Menu extends CoreEntity implements Stringable
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 Menu
121
     */
122
    public static function build(?WP_Term $menu, $args = []): ?self
64✔
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) {
×
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
    protected function __construct(?WP_Term $term, array $args = [])
72✔
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
        $this->theme_location = Timber::get_menu_location($term);
64✔
297
        if ($this->theme_location) {
64✔
298
            $this->args->theme_location = $this->theme_location;
9✔
299
        }
300

301
        $this->import($term);
64✔
302
        $this->ID = $this->term_id;
64✔
303
        $this->id = $this->term_id;
64✔
304
        $this->wp_object = $term;
64✔
305
        $this->title = $this->name;
64✔
306
    }
307

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

320
    /**
321
     * Convert menu items into MenuItem objects
322
     *
323
     * @return MenuItem[]
324
     */
325
    protected function convert_menu_items(array $menu_items): array
70✔
326
    {
327
        $menu_item_factory = new MenuItemFactory();
70✔
328
        return \array_map(fn ($item): MenuItem => $menu_item_factory->from($item, $this), $menu_items);
70✔
329
    }
330

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

349
    /**
350
     * @internal
351
     * @return MenuItem[]
352
     */
353
    protected function order_children(array $items): array
70✔
354
    {
355
        $items_by_id = [];
70✔
356
        $menu_items = [];
70✔
357

358
        foreach ($items as $item) {
70✔
359
            // Index each item by its ID
360
            $items_by_id[$item->ID] = $item;
61✔
361
        }
362

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

376
    /**
377
     * @internal
378
     */
379
    protected function strip_to_depth_limit(array $menu_items, int $current = 1): array
70✔
380
    {
381
        $depth = (int) $this->depth; // Confirms still int.
70✔
382
        if ($depth <= 0) {
70✔
383
            return $menu_items;
67✔
384
        }
385

386
        foreach ($menu_items as &$current_item) {
6✔
387
            if ($current === $depth) {
6✔
388
                $current_item->remove_class('menu-item-has-children');
6✔
389
                $current_item->children = false;
6✔
390
                continue;
6✔
391
            }
392

393
            $current_item->children = $this->strip_to_depth_limit($current_item->children, $current + 1);
2✔
394
        }
395

396
        return $menu_items;
6✔
397
    }
398

399
    /**
400
     * Gets a menu meta value.
401
     *
402
     * @api
403
     * @deprecated 2.0.0, use `{{ menu.meta('field_name') }}` instead.
404
     * @see \Timber\Menu::meta()
405
     *
406
     * @param string $field_name The field name for which you want to get the value.
407
     * @return mixed The meta field value.
408
     */
409
    public function get_field($field_name = null)
×
410
    {
411
        Helper::deprecated(
×
412
            "{{ menu.get_field('field_name') }}",
×
413
            "{{ menu.meta('field_name') }}",
×
414
            '2.0.0'
×
415
        );
×
416

417
        return $this->meta($field_name);
×
418
    }
419

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

441
        return [];
×
442
    }
443

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

478
        if (empty($this->items)) {
8✔
479
            $this->_current_item = false;
1✔
480
            return $this->_current_item;
1✔
481
        }
482

483
        if (!isset($this->_current_item)) {
7✔
484
            $current = $this->traverse_items_for_current(
7✔
485
                $this->items,
7✔
486
                $depth
7✔
487
            );
7✔
488

489
            if (\is_null($depth)) {
7✔
490
                $this->_current_item = $current;
5✔
491
            } else {
492
                return $current;
3✔
493
            }
494
        }
495

496
        return $this->_current_item;
5✔
497
    }
498

499
    /**
500
     * Alias for current_top_level_item(1).
501
     *
502
     * @return MenuItem the current top-level `Timber\MenuItem` object.
503
     */
504
    public function current_top_level_item()
1✔
505
    {
506
        return $this->current_item(1);
1✔
507
    }
508

509
    /**
510
     * Traverse an array of MenuItems in search of the current item.
511
     *
512
     * @internal
513
     * @param array $items the items to traverse.
514
     */
515
    private function traverse_items_for_current($items, $depth)
7✔
516
    {
517
        $current = false;
7✔
518
        $currentDepth = 1;
7✔
519
        $i = 0;
7✔
520

521
        while (isset($items[$i])) {
7✔
522
            $item = $items[$i];
7✔
523

524
            if ($item->current) {
7✔
525
                // cache this item for subsequent calls.
526
                $current = $item;
3✔
527
                // stop looking.
528
                break;
3✔
529
            } elseif ($item->current_item_ancestor) {
7✔
530
                // we found an ancestor,
531
                // but keep looking for a more precise match.
532
                $current = $item;
6✔
533

534
                if ($currentDepth === $depth) {
6✔
535
                    // we're at max traversal depth.
536
                    return $current;
3✔
537
                }
538

539
                // we're in the right subtree, so go deeper.
540
                if ($item->children()) {
5✔
541
                    // reset the counter, since we're at a new level.
542
                    $items = $item->children();
3✔
543
                    $i = 0;
3✔
544
                    $currentDepth++;
3✔
545
                    continue;
3✔
546
                }
547
            }
548

549
            $i++;
4✔
550
        }
551

552
        return $current;
5✔
553
    }
554

555
    public function __toString()
1✔
556
    {
557
        static $menu_id_slugs = [];
1✔
558

559
        $args = $this->args;
1✔
560

561
        $items = '';
1✔
562
        $nav_menu = '';
1✔
563
        $show_container = false;
1✔
564

565
        if ($args->container) {
1✔
566
            /**
567
            * Filters the list of HTML tags that are valid for use as menu containers.
568
            *
569
            * @since 3.0.0
570
            *
571
            * @param string[] $tags The acceptable HTML tags for use as menu containers.
572
            *                       Default is array containing 'div' and 'nav'.
573
            */
574
            $allowed_tags = \apply_filters('wp_nav_menu_container_allowedtags', ['div', 'nav']);
1✔
575

576
            if (\is_string($args->container) && \in_array($args->container, $allowed_tags, true)) {
1✔
577
                $show_container = true;
1✔
578
                $class = $args->container_class ? ' class="' . \esc_attr($args->container_class) . '"' : ' class="menu-' . $this->slug . '-container"';
1✔
579
                $id = $args->container_id ? ' id="' . \esc_attr($args->container_id) . '"' : '';
1✔
580
                $aria_label = ('nav' === $args->container && $args->container_aria_label) ? ' aria-label="' . \esc_attr($args->container_aria_label) . '"' : '';
1✔
581
                $nav_menu .= '<' . $args->container . $id . $class . $aria_label . '>';
1✔
582
            }
583
        }
584

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

587
        // Attributes.
588
        if (!empty($args->menu_id)) {
1✔
589
            $wrap_id = $args->menu_id;
1✔
590
        } else {
591
            $wrap_id = 'menu-' . $this->slug;
×
592

593
            while (\in_array($wrap_id, $menu_id_slugs, true)) {
×
594
                if (\preg_match('#-(\d+)$#', (string) $wrap_id, $matches)) {
×
595
                    $wrap_id = \preg_replace('#-(\d+)$#', '-' . ++$matches[1], (string) $wrap_id);
×
596
                } else {
597
                    $wrap_id = $wrap_id . '-1';
×
598
                }
599
            }
600
        }
601
        $menu_id_slugs[] = $wrap_id;
1✔
602

603
        $wrap_class = $args->menu_class ?: '';
1✔
604

605
        $nav_menu .= \sprintf($args->items_wrap, \esc_attr($wrap_id), \esc_attr($wrap_class), $items);
1✔
606
        if ($show_container) {
1✔
607
            $nav_menu .= '</' . $args->container . '>';
1✔
608
        }
609

610
        return $nav_menu;
1✔
611
    }
612

613
    /**
614
     * Checks whether the current user can edit the menu.
615
     *
616
     * @api
617
     * @since 2.0.0
618
     * @return bool
619
     */
620
    public function can_edit(): bool
1✔
621
    {
622
        return \current_user_can('edit_theme_options');
1✔
623
    }
624
}
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

© 2026 Coveralls, Inc