├── .gitignore
├── pint.json
├── src
├── Exceptions
│ └── MenuLifecycleException.php
├── Providers
│ └── NaviServiceProvider.php
├── Facades
│ └── Navi.php
├── Console
│ ├── stubs
│ │ └── view.stub
│ ├── NaviListCommand.php
│ └── NaviMakeCommand.php
├── MenuBuilder.php
└── Navi.php
├── .editorconfig
├── plugin.php
├── composer.json
├── LICENSE.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | composer.lock
3 |
--------------------------------------------------------------------------------
/pint.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [
3 | "examples/vanilla"
4 | ],
5 | "notName": [
6 | "plugin.php"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/src/Exceptions/MenuLifecycleException.php:
--------------------------------------------------------------------------------
1 | getApplication();
24 |
25 | $app->register(Log1x\Navi\Providers\NaviServiceProvider::class);
26 |
27 | $app->alias('navi', Log1x\Navi\Facades\Navi::class);
28 | }, 20);
29 |
--------------------------------------------------------------------------------
/src/Providers/NaviServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->bind('navi', fn () => Navi::make());
19 | }
20 |
21 | /**
22 | * Bootstrap any application services.
23 | *
24 | * @return void
25 | */
26 | public function boot()
27 | {
28 | if ($this->app->runningInConsole()) {
29 | $this->commands([
30 | Console\NaviListCommand::class,
31 | Console\NaviMakeCommand::class,
32 | ]);
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "log1x/navi",
3 | "type": "package",
4 | "license": "MIT",
5 | "description": "A developer-friendly alternative to the WordPress NavWalker.",
6 | "authors": [
7 | {
8 | "name": "Brandon Nifong",
9 | "email": "brandon@tendency.me"
10 | }
11 | ],
12 | "keywords": [
13 | "wordpress",
14 | "wordpress-plugin",
15 | "navwalker"
16 | ],
17 | "support": {
18 | "issues": "https://github.com/log1x/navi/issues"
19 | },
20 | "autoload": {
21 | "psr-4": {
22 | "Log1x\\Navi\\": "src/"
23 | }
24 | },
25 | "require": {
26 | "php": "^8.0"
27 | },
28 | "require-dev": {
29 | "laravel/pint": "^1.14",
30 | "roots/acorn": "^4.3"
31 | },
32 | "extra": {
33 | "acorn": {
34 | "providers": [
35 | "Log1x\\Navi\\Providers\\NaviServiceProvider"
36 | ],
37 | "aliases": {
38 | "Navi": "Log1x\\Navi\\Facades\\Navi"
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Facades/Navi.php:
--------------------------------------------------------------------------------
1 | {{ default }},
3 | 'inactive' => 'hover:text-blue-500',
4 | 'active' => 'text-blue-500',
5 | ])
6 |
7 | @php($menu = Navi::build($name))
8 |
9 | @if ($menu->isNotEmpty())
10 |
11 | @foreach ($menu->all() as $item)
12 | - classes,
14 | $inactive => ! $item->active,
15 | $active => $item->active,
16 | ])>
17 |
18 | {{ $item->label }}
19 |
20 |
21 | @if ($item->children)
22 |
23 | @foreach ($item->children as $child)
24 | - classes,
26 | $inactive => ! $child->active,
27 | $active => $child->active,
28 | ])>
29 |
30 | {{ $child->label }}
31 |
32 |
33 | @endforeach
34 |
35 | @endif
36 |
37 | @endforeach
38 |
39 | @endif
40 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Brandon Nifong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Console/NaviListCommand.php:
--------------------------------------------------------------------------------
1 | map(fn ($label, $value) => $menus->firstWhere('term_id', $locations->get($value)))
36 | ->map(fn ($menu, $location) => collect([
37 | $location,
38 | $menu?->name ?? 'Unassigned',
39 | $menu?->count ?? 0,
40 | ])->map(fn ($value) => $menu?->name ? $value : "{$value}>"));
41 |
42 | $this->table([
43 | 'Location>',
44 | 'Assigned Menu>',
45 | 'Menu Items>',
46 | ], $rows, tableStyle: 'box');
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/MenuBuilder.php:
--------------------------------------------------------------------------------
1 | 'current',
17 | 'activeAncestor' => 'current_item_ancestor',
18 | 'activeParent' => 'current_item_parent',
19 | 'classes' => 'classes',
20 | 'dbId' => 'db_id',
21 | 'description' => 'description',
22 | 'id' => 'ID',
23 | 'label' => 'title',
24 | 'object' => 'object',
25 | 'objectId' => 'object_id',
26 | 'order' => 'menu_order',
27 | 'parent' => 'menu_item_parent',
28 | 'slug' => 'post_name',
29 | 'target' => 'target',
30 | 'title' => 'attr_title',
31 | 'type' => 'type',
32 | 'url' => 'url',
33 | 'xfn' => 'xfn',
34 | ];
35 |
36 | /**
37 | * The classes to remove from menu items.
38 | */
39 | protected array $withoutClasses = [];
40 |
41 | /**
42 | * Make a new Menu Builder instance.
43 | */
44 | public static function make(): self
45 | {
46 | return new static;
47 | }
48 |
49 | /**
50 | * Build the navigation menu.
51 | */
52 | public function build(array $menu = []): array
53 | {
54 | $this->menu = $this->filter($menu);
55 |
56 | if (! $this->menu) {
57 | return [];
58 | }
59 |
60 | $this->menu = array_combine(
61 | array_column($this->menu, 'ID'),
62 | $this->menu
63 | );
64 |
65 | return $this->handle(
66 | $this->map($this->menu)
67 | );
68 | }
69 |
70 | /**
71 | * Filter the menu items.
72 | */
73 | protected function filter(array $menu = []): array
74 | {
75 | $menu = array_filter($menu, fn ($item) => is_a($item, 'WP_Post') || is_a($item, 'WPML_LS_Menu_Item'));
76 |
77 | if (! $menu) {
78 | return [];
79 | }
80 |
81 | _wp_menu_item_classes_by_context($menu);
82 |
83 | return array_map(function ($item) {
84 | $classes = array_filter($item->classes, function ($class) {
85 | foreach ($this->withoutClasses as $value) {
86 | if (str_starts_with($class, $value)) {
87 | return false;
88 | }
89 | }
90 |
91 | return true;
92 | });
93 |
94 | $item->classes = is_array($classes) ? implode(' ', $classes) : $classes;
95 |
96 | foreach ($item as $key => $value) {
97 | if (! $value) {
98 | $item->{$key} = false;
99 | }
100 | }
101 |
102 | return $item;
103 | }, $menu);
104 | }
105 |
106 | /**
107 | * Map the menu items into an object.
108 | */
109 | protected function map(array $menu = []): array
110 | {
111 | return array_map(function ($item) {
112 | $result = [];
113 |
114 | foreach ($this->attributes as $key => $value) {
115 | $result[$key] = $item->{$value};
116 | }
117 |
118 | $result['parentObjectId'] = ! empty($result['parent']) && ! empty($this->menu[$result['parent']])
119 | ? $this->menu[$result['parent']]->object_id
120 | : false;
121 |
122 | return (object) $result;
123 | }, $menu);
124 | }
125 |
126 | /**
127 | * Handle the menu item hierarchy.
128 | */
129 | protected function handle(array $items, string|int $parent = 0): array
130 | {
131 | $menu = [];
132 |
133 | foreach ($items as $item) {
134 | if ($item->parent != $parent) {
135 | continue;
136 | }
137 |
138 | $item->children = $this->handle($items, $item->id);
139 |
140 | $menu[$item->id] = $item;
141 | }
142 |
143 | return $menu;
144 | }
145 |
146 | /**
147 | * Remove classes from menu items.
148 | */
149 | public function withoutClasses(array $classes = []): self
150 | {
151 | $this->withoutClasses = $classes;
152 |
153 | return $this;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/Console/NaviMakeCommand.php:
--------------------------------------------------------------------------------
1 | argument('name'))
45 | ->lower()
46 | ->trim();
47 |
48 | $default = $this->option('default') ?? 'primary_navigation';
49 |
50 | $locations = collect(get_registered_nav_menus())
51 | ->take(5)
52 | ->map(fn ($name, $slug) => $slug === $default
53 | ? "{$name}: >"
54 | : "{$name}: >"
55 | );
56 |
57 | $this->components->info("Navi component > is ready for use.");
58 |
59 | if ($locations->isEmpty()) {
60 | $this->components->warn('Your theme does not appear to have any registered navigation menu locations.');
61 |
62 | return;
63 | }
64 |
65 | $this->components->bulletList($locations->all());
66 | }
67 |
68 | /**
69 | * Build the class with the given name.
70 | *
71 | * @param string $name
72 | * @return string
73 | */
74 | protected function buildClass($name)
75 | {
76 | $contents = parent::buildClass($name);
77 |
78 | return str_replace(
79 | '{{ default }}',
80 | $this->option('default') ? Str::wrap($this->option('default'), "'") : 'null',
81 | $contents,
82 | );
83 | }
84 |
85 | /**
86 | * Get the destination view path.
87 | *
88 | * @param string $name
89 | * @return string
90 | */
91 | protected function getPath($name)
92 | {
93 | $path = $this->viewPath(
94 | str_replace('.', '/', 'components.'.$this->getView()).'.blade.php'
95 | );
96 |
97 | if (! $this->files->isDirectory(dirname($path))) {
98 | $this->files->makeDirectory(dirname($path), 0777, true, true);
99 | }
100 |
101 | return $path;
102 | }
103 |
104 | /**
105 | * Get the view name relative to the components directory.
106 | *
107 | * @return string
108 | */
109 | protected function getView()
110 | {
111 | $name = str_replace('\\', '/', $this->argument('name'));
112 |
113 | return collect(explode('/', $name))
114 | ->map(fn ($part) => Str::kebab($part))
115 | ->implode('.');
116 | }
117 |
118 | /**
119 | * Get the desired view name from the input.
120 | *
121 | * @return string
122 | */
123 | protected function getNameInput()
124 | {
125 | $name = trim($this->argument('name'));
126 |
127 | $name = str_replace(['\\', '.'], '/', $this->argument('name'));
128 |
129 | return $name;
130 | }
131 |
132 | /**
133 | * Get the stub file for the generator.
134 | *
135 | * @return string
136 | */
137 | protected function getStub()
138 | {
139 | return $this->resolveStubPath(
140 | '/stubs/view.stub',
141 | );
142 | }
143 |
144 | /**
145 | * Resolve the fully-qualified path to the stub.
146 | *
147 | * @param string $stub
148 | * @return string
149 | */
150 | protected function resolveStubPath($stub)
151 | {
152 | return file_exists($customPath = $this->laravel->basePath(trim($stub, '/')))
153 | ? $customPath
154 | : __DIR__.$stub;
155 | }
156 |
157 | /**
158 | * Prompt for missing input arguments using the returned questions.
159 | *
160 | * @return array
161 | */
162 | protected function promptForMissingArgumentsUsing()
163 | {
164 | return [
165 | 'name' => [
166 | 'What should the Navi component be named?',
167 | 'E.g. Navigation',
168 | ],
169 | ];
170 | }
171 |
172 | /**
173 | * Get the console command arguments.
174 | *
175 | * @return array
176 | */
177 | protected function getOptions()
178 | {
179 | return [
180 | ['default', 'd', InputOption::VALUE_OPTIONAL, 'The default menu name'],
181 | ['force', 'f', InputOption::VALUE_NONE, 'Create the view component even if the component already exists'],
182 | ];
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/src/Navi.php:
--------------------------------------------------------------------------------
1 | $value) {
41 | $this->items[$key] = $value;
42 | }
43 | }
44 |
45 | /**
46 | * Make a new Navi instance.
47 | */
48 | public static function make(array $items = []): self
49 | {
50 | return new static($items);
51 | }
52 |
53 | /**
54 | * Build the navigation menu items.
55 | */
56 | public function build(mixed $menu = null): self
57 | {
58 | $menu = $menu ?? $this->default;
59 |
60 | if (is_string($menu)) {
61 | $locations = get_nav_menu_locations();
62 |
63 | if (array_key_exists($menu, $locations)) {
64 | $menu = $locations[$menu];
65 |
66 | if (has_filter('wpml_object_id')) {
67 | $menu = apply_filters('wpml_object_id', $menu, 'nav_menu');
68 | }
69 | }
70 | }
71 |
72 | $this->menu = wp_get_nav_menu_object($menu);
73 |
74 | $items = wp_get_nav_menu_items($this->menu);
75 |
76 | $this->items = MenuBuilder::make()
77 | ->withoutClasses($this->disallowedClasses())
78 | ->build($items ?: []);
79 |
80 | return $this;
81 | }
82 |
83 | /**
84 | * Retrieve data from the WordPress menu object.
85 | */
86 | public function get(?string $key = null, mixed $default = null): mixed
87 | {
88 | if (! $this->menu) {
89 | return $default;
90 | }
91 |
92 | if (! empty($key)) {
93 | return $this->menu->{$key} ?? $default;
94 | }
95 |
96 | return $this->menu;
97 | }
98 |
99 | /**
100 | * Determine if the menu is empty.
101 | */
102 | public function isEmpty(): bool
103 | {
104 | return empty($this->all());
105 | }
106 |
107 | /**
108 | * Determine if the menu is not empty.
109 | */
110 | public function isNotEmpty(): bool
111 | {
112 | return ! $this->isEmpty();
113 | }
114 |
115 | /**
116 | * Retrieve the menu items.
117 | */
118 | public function all(): array
119 | {
120 | return $this->items;
121 | }
122 |
123 | /**
124 | * Retrieve the menu items as an array.
125 | */
126 | public function toArray(): array
127 | {
128 | return $this->all();
129 | }
130 |
131 | /**
132 | * Retrieve the menu items as JSON.
133 | */
134 | public function toJson(int $options = 0): string
135 | {
136 | return json_encode($this->toArray(), $options);
137 | }
138 |
139 | /**
140 | * The classes to allow on menu items.
141 | *
142 | * @throws \Log1x\Navi\Exceptions\MenuLifecycleException
143 | */
144 | public function withClasses(string|array $classes): self
145 | {
146 | if ($this->menu) {
147 | throw new MenuLifecycleException('Classes must be set before building the menu.');
148 | }
149 |
150 | $classes = is_string($classes)
151 | ? explode(' ', $classes)
152 | : $classes;
153 |
154 | $this->disallowedClasses = array_diff($this->disallowedClasses, $classes);
155 |
156 | return $this;
157 | }
158 |
159 | /**
160 | * The classes to remove from menu items.
161 | *
162 | * @throws \Log1x\Navi\Exceptions\MenuLifecycleException
163 | */
164 | public function withoutClasses(string|array $classes): self
165 | {
166 | if ($this->menu) {
167 | throw new MenuLifecycleException('Classes must be set before building the menu.');
168 | }
169 |
170 | $classes = is_string($classes)
171 | ? explode(' ', $classes)
172 | : $classes;
173 |
174 | $this->disallowedClasses = array_unique([
175 | ...$this->disallowedClasses,
176 | ...$classes,
177 | ]);
178 |
179 | return $this;
180 | }
181 |
182 | /**
183 | * Allow the disallowed classes on menu items.
184 | */
185 | public function withDefaultClasses(): self
186 | {
187 | $this->disallowedClasses = [];
188 |
189 | return $this;
190 | }
191 |
192 | /**
193 | * Retrieve the disallowed classes.
194 | */
195 | protected function disallowedClasses(): array
196 | {
197 | return array_merge(...array_map(fn ($class) => [
198 | $class,
199 | str_replace('-', '_', $class),
200 | ], $this->disallowedClasses));
201 | }
202 |
203 | /**
204 | * Dynamically retrieve a Navi item.
205 | */
206 | public function __get(string $key): mixed
207 | {
208 | return $this->get($key);
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Navi
2 |
3 | 
4 | 
5 | 
6 |
7 | Hate the WordPress NavWalker? **Me too**.
8 |
9 | Navi is a developer-friendly alternative to the NavWalker. Easily build your WordPress menus using an iterable object inside of a template/view.
10 |
11 | ## Requirements
12 |
13 | - [PHP](https://secure.php.net/manual/en/install.php) >= 8.0
14 |
15 | ## Installation
16 |
17 | ### Bedrock (or Sage)
18 |
19 | Install via Composer:
20 |
21 | ```bash
22 | $ composer require log1x/navi
23 | ```
24 |
25 | ### Manual
26 |
27 | Download the [latest release](https://github.com/Log1x/navi/releases/latest) `.zip` and install into `wp-content/plugins`.
28 |
29 | ## Usage
30 |
31 | Building your menu can be done by passing your menu location to `Navi::make()->build()`:
32 |
33 | ```php
34 | use Log1x\Navi\Navi;
35 |
36 | $menu = Navi::make()->build('primary_navigation');
37 | ```
38 |
39 | By default, `build()` uses `primary_navigation` if no menu location is specified.
40 |
41 | Retrieving an array of menu items can be done using `all()`:
42 |
43 | ```php
44 | if ($menu->isNotEmpty()) {
45 | return $menu->all();
46 | }
47 | ```
48 |
49 | > [!NOTE]
50 | > Check out the [**examples**](examples) folder to see how to use Navi in your project.
51 |
52 | ### Menu Item Classes
53 |
54 | By default, Navi removes the default WordPress classes from menu items such as `menu-item` and `current-menu-item` giving you full control over your menu markup while still passing through custom classes.
55 |
56 | If you would like these classes to be included on your menu items, you may call `withDefaultClasses()` before building your menu:
57 |
58 | ```php
59 | $menu = Navi::make()->withDefaultClasses()->build();
60 | ```
61 |
62 | In some situations, plugins may add their own classes to menu items. If you would like to prevent these classes from being added, you may pass an array of partial strings to `withoutClasses()` match against when building.
63 |
64 | ```php
65 | $menu = Navi::make()->withoutClasses(['shop-'])->build();
66 | ```
67 |
68 | ### Accessing Menu Object
69 |
70 | When building the navigation menu, Navi retains the menu object and makes it available using the `get()` method.
71 |
72 | By default, `get()` returns the raw [`wp_get_nav_menu_object()`](https://codex.wordpress.org/Function_Reference/wp_get_nav_menu_object) allowing you to access it directly.
73 |
74 | ```php
75 | $menu->get()->name;
76 | ```
77 |
78 | Optionally, you may pass a `key` and `default` to call a specific object key with a fallback when the value is blank:
79 |
80 | ```php
81 | $menu->get('name', 'My menu title');
82 | ```
83 |
84 | ### Accessing Page Objects
85 |
86 | If your menu item is linked to a page object (e.g. not a custom link) – you can retrieve the ID of the page using the `objectId` attribute.
87 |
88 | Below is an example of getting the post type of the current menu item:
89 |
90 | ```php
91 | $type = get_post_type($item->objectId)
92 | ```
93 |
94 | ### Accessing Custom Fields
95 |
96 | In a scenario where you need to access a custom field attached directly to your menu item – you can retrieve the ID of the menu item using the `id` attribute.
97 |
98 | Below we'll get a label override field attached to our menu [using ACF](https://www.advancedcustomfields.com/resources/adding-fields-menus/) – falling back to the default menu label if the field is empty.
99 |
100 | ```php
101 | $label = get_field('custom_menu_label', $item->id) ?: $item->label;
102 | ```
103 |
104 | ### Acorn Usage
105 |
106 | If you are using Navi alongside [Acorn](https://roots.io/acorn/) (e.g. Sage), you may generate a usable view component using Acorn's CLI:
107 |
108 | ```sh
109 | $ wp acorn navi:make Menu
110 | ```
111 |
112 | Once generated, you may use the [view component](https://laravel.com/docs/11.x/blade#components) in an existing view like so:
113 |
114 | ```php
115 |
116 | ```
117 |
118 | To list all registered locations and their assigned menus, you can use the list command:
119 |
120 | ```sh
121 | $ wp acorn navi:list
122 | ```
123 |
124 | ## Example Output
125 |
126 | When calling `build()`, Navi will retrieve the WordPress navigation menu assigned to the passed location and build out an array containing the menu items.
127 |
128 | An example of the menu output can be seen below:
129 |
130 | ```php
131 | array [
132 | 5 => {
133 | +"active": true
134 | +"activeAncestor": false
135 | +"activeParent": false
136 | +"classes": "example"
137 | +"dbId": 5
138 | +"description": false
139 | +"id": 5
140 | +"label": "Home"
141 | +"object": "page"
142 | +"objectId": "99"
143 | +"parent": false
144 | +"slug": "home"
145 | +"target": "_blank"
146 | +"title": false
147 | +"type": "post_type"
148 | +"url": "https://sage.test/"
149 | +"xfn": false
150 | +"order": 1
151 | +"parentObjectId": false
152 | +"children": false
153 | }
154 | 6 => {
155 | +"active": false
156 | +"activeAncestor": false
157 | +"activeParent": false
158 | +"classes": false
159 | +"dbId": 6
160 | +"description": false
161 | +"id": 6
162 | +"label": "Sample Page"
163 | +"object": "page"
164 | +"objectId": "100"
165 | +"parent": false
166 | +"slug": "sample-page"
167 | +"target": false
168 | +"title": false
169 | +"type": "post_type"
170 | +"url": "https://sage.test/sample-page/"
171 | +"xfn": false
172 | +"order": 2
173 | +"parentObjectId": false
174 | +"children": array [
175 | 7 => {
176 | +"active": false
177 | +"activeAncestor": false
178 | +"activeParent": false
179 | +"classes": false
180 | +"dbId": 7
181 | +"description": false
182 | +"id": 7
183 | +"label": "Example"
184 | +"object": "custom"
185 | +"objectId": "101"
186 | +"parent": 6
187 | +"slug": "example"
188 | +"target": false
189 | +"title": false
190 | +"type": "custom"
191 | +"url": "#"
192 | +"xfn": false
193 | +"order": 3
194 | +"parentObjectId": 100
195 | +"children": array [
196 | ...
197 | ]
198 | }
199 | ]
200 | }
201 | ]
202 | ```
203 |
204 | ## Bug Reports
205 |
206 | If you discover a bug in Navi, please [open an issue](https://github.com/Log1x/navi/issues).
207 |
208 | ## Contributing
209 |
210 | Contributing whether it be through PRs, reporting an issue, or suggesting an idea is encouraged and appreciated.
211 |
212 | ## License
213 |
214 | Navi is provided under the [MIT License](LICENSE.md).
215 |
--------------------------------------------------------------------------------