├── .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 | 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 | ![Latest Stable Version](https://img.shields.io/packagist/v/log1x/navi.svg?style=flat-square) 4 | ![Total Downloads](https://img.shields.io/packagist/dt/log1x/navi.svg?style=flat-square) 5 | ![Build Status](https://img.shields.io/github/actions/workflow/status/log1x/navi/main.yml?branch=master&style=flat-square) 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 | --------------------------------------------------------------------------------