├── CHANGELOG.md ├── LICENSE ├── composer.json └── src └── Knp └── Menu ├── Factory ├── CoreExtension.php └── ExtensionInterface.php ├── FactoryInterface.php ├── Integration └── Symfony │ └── RoutingExtension.php ├── ItemInterface.php ├── Iterator ├── CurrentItemFilterIterator.php ├── DisplayedItemFilterIterator.php └── RecursiveItemIterator.php ├── Loader ├── ArrayLoader.php ├── LoaderInterface.php └── NodeLoader.php ├── Matcher ├── Matcher.php ├── MatcherInterface.php └── Voter │ ├── CallbackVoter.php │ ├── RegexVoter.php │ ├── RouteVoter.php │ ├── UriVoter.php │ └── VoterInterface.php ├── MenuFactory.php ├── MenuItem.php ├── NodeInterface.php ├── Provider ├── ArrayAccessProvider.php ├── ChainProvider.php ├── LazyProvider.php ├── MenuProviderInterface.php └── PsrProvider.php ├── Renderer ├── ArrayAccessProvider.php ├── ListRenderer.php ├── PsrProvider.php ├── Renderer.php ├── RendererInterface.php ├── RendererProviderInterface.php └── TwigRenderer.php ├── Resources └── views │ ├── knp_menu.html.twig │ ├── knp_menu_base.html.twig │ └── knp_menu_ordered.html.twig ├── Twig ├── Helper.php ├── MenuExtension.php └── MenuRuntimeExtension.php └── Util └── MenuManipulator.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.5 (2024-03-23) 2 | 3 | * Added CallbackVoter 4 | * Removed support for unsupported PHP version 8.0 5 | 6 | ## 3.4 (2023-05-17) 7 | 8 | * Removed support for unsupported PHP version 7.4 9 | 10 | ## 3.3 (2021-10-28) 11 | 12 | * Added support for Symfony 6 13 | 14 | ## 3.2 (2021-05-28) 15 | 16 | * Remove Symfony 6 deprecations 17 | * Enforce phpstan rules (max level) 18 | 19 | ## 3.1 (2019-12-01) 20 | 21 | * Allowed Symfony 5 components 22 | * Removed support for unsupported Symfony versions (4.0 and 4.1) 23 | * Allowed Twig 3 24 | 25 | ## 3.0 (2019-09-02) 26 | 27 | * Raised PHP requirements 28 | * [BC break] Enforced strong types on all interfaces and classes 29 | * [BC break] Removed deprecated features. Specifically, MenuFactory and MenuItem are not accepting a `null` name anymore 30 | 31 | ## 2.4 (2019-07-29) 32 | 33 | * Fixed Twig deprecations 34 | * Switched to namespaced Twig 35 | * Fixed sprintf use 36 | 37 | ## 2.3 (2017-11-18) 38 | 39 | * Deprecated the Silex 1 KnpMenuServiceProvider. Use the `knplabs/knp-menu-silex` package instead. 40 | * Fixed RouteVoter to also match on non-string request arguments like integers as long as both string representations are identical. 41 | * Add Symfony 4 support 42 | 43 | ## 2.2 (2016-09-22) 44 | 45 | * Added a new function to twig: `knp_menu_get_current_item` 46 | 47 | ## 2.1.1 (2016-01-08) 48 | 49 | * Made compatible with Symfony 3 50 | 51 | ## 2.1.0 (2015-09-20) 52 | 53 | * Added a new function to twig: `knp_menu_get_breadcrumbs_array` 54 | * Added a new filter to twig: `knp_menu_as_string` 55 | * Added 2 new tests to twig: `knp_menu_current`, `knp_menu_ancestor` 56 | * Made the templates compatible with Twig 2 57 | * Add menu and renderer providers supporting any ArrayAccess implementations. The 58 | Pimple-based providers (supporting only Pimple 1) are deprecated in favor of these 59 | new providers. 60 | 61 | ## 2.0.1 (2014-08-01) 62 | 63 | * Fixed voter conventions on RouteVoter 64 | 65 | ## 2.0.0 (2014-07-18) 66 | 67 | * [BC break] Clean code and removed the BC layer 68 | 69 | ## 2.0.0 beta 1 (2014-06-19) 70 | 71 | * [BC break] Added the new `Integration` namespace and removed the `Silex` one. 72 | * Added a new Voter based on regular expression: `Knp\Menu\Matcher\Voter\RegexVoter` 73 | 74 | ## 2.0.0 alpha 2 (2014-05-01) 75 | 76 | * [BC break] Changed the TwigRenderer to accept a menu template only as a string 77 | * [BC break] Refactored the way of rendering twig templates. Every template should extend 78 | the `knp_menu.html.twig` template. 79 | * Introduced extension points in the MenuFactory through `Knp\Menu\Factory\ExtensionInterface` 80 | * [BC break compared to 2.0 alpha 1] The inheritance extension points introduced in alpha1 are 81 | deprecated in favor of extensions and will be removed before the stable release. 82 | * `Knp\Menu\Silex\RouterAwareFactory` is deprecated in favor of `Knp\Menu\Silex\RoutingExtension`. 83 | * [BC break] Deprecated the methods `createFromArray` and `createFromNode` in the MenuFactory and 84 | removed them from `Knp\Menu\FactoryInterface`. Use `Knp\Menu\Loader\ArrayLoader` and 85 | `Knp\Menu\Loader\NodeLoader` instead. 86 | * [BC break] Deprecated the methods `moveToPosition`, `moveToFirstPosition`, `moveToLastPosition`, 87 | `moveChildToPosition`, `callRecursively`, `toArray`, `getPathAsString` and `getBreadcrumbsArray` 88 | in the MenuItem and removed them from `Knp\Menu\ItemInterface`. Use `Knp\Menu\Util\MenuManipulator` 89 | instead. 90 | * Made the RouterVoter compatible with SensioFrameworkExtraBundle param converters 91 | * Added the possibility to match routes using a regex on their name in the RouterVoter 92 | * [BC break compared to 2.0 alpha 1] Refactored the RouterVoter to make it more flexible 93 | The way to pass routes in the item extras has changed. 94 | 95 | Before: 96 | 97 | ```php 98 | 'extras' => array( 99 | 'routes' => array('foo', 'bar'), 100 | 'routeParameters' => array('foo' => array('id' => 4)), 101 | ) 102 | ``` 103 | 104 | After: 105 | 106 | ```php 107 | 'extras' => array( 108 | 'routes' => array( 109 | array('route' => 'foo', 'parameters' => array('id' => 4)), 110 | 'bar', 111 | ) 112 | ) 113 | ``` 114 | 115 | The old syntax is kept until the final release, but using it will trigger an E_USER_DEPRECATED error. 116 | 117 | ## 2.0.0 alpha 1 (2013-06-23) 118 | 119 | * Added protected methods `buildOptions` and `configureItem` in the MenuFactory as extension point by inheritance 120 | * [BC break] Refactored the way to mark items as current 121 | ``setCurrentUri``, ``getCurrentUri`` and ``getCurrentItem`` have been removed from the ItemInterface. 122 | Determining the current items is now delegated to a matcher, and the default implementation 123 | uses voters to apply the matching. Getting the current items can be done thanks to the CurrentItemFilterIterator. 124 | * [BC break] The signature of the CurrentItemFilterIterator constructor changed to accept the item matcher 125 | * [BC break] Changed the format of the breadcrumb array 126 | Instead of storing the elements with the label as key and the uri as value 127 | the array now stores an array of array elements with 3 keys: `label`, `uri` and `item`. 128 | 129 | ## 1.1.2 (2012-06-10) 130 | 131 | * Updated the Silex service provider for the change in the interface 132 | 133 | ## 1.1.1 (2012-05-17) 134 | 135 | * Added the children attributes and the extras in the array export 136 | 137 | ## 1.1.0 (2012-05-17) 138 | 139 | * Marked `Knp\Menu\ItemInterface::getCurrentItem` as deprecated 140 | * Added a recursive filter iterator keeping only displayed items 141 | * Added a filter iterator keeping only current items 142 | * Added a recursive iterator for the item 143 | * Fixed building an array of breadcrumbs when a label has only digits 144 | * Added a way to mark a label as safe 145 | * Refactored the ListRenderer to be consistent with the TwigRenderer and provide the same extension points 146 | * Added a way to attach extra data to an item 147 | * Removed unnecessary optimization in the TwigRenderer 148 | * Added some whitespace control in the Twig template to ensure an empty rendering is really empty 149 | * [BC break] Use the childrenAttributes for the root instead of the attributes 150 | * Made the default options configurable for the TwigRenderer 151 | * Added the support for menu registered as factory in PimpleProvider 152 | * Added a way to use the options in `knp_menu_get()` in Twig templates 153 | * Added an array of options for the MenuProviderInterface 154 | * Added a template to render an ordered list 155 | * Refactored the template a bit to make it easier to use an ordered list 156 | * Allow omitting the name of the child in `fromArray` (the key is used instead) 157 | 158 | ## 1.0.0 (2011-12-03) 159 | 160 | * Add composer.json file 161 | * Added more flexible list element blocks 162 | * Add support for attributes on the children collection. 163 | * Added a default renderer 164 | * Added a ChainProvider for the menus. 165 | * Added the Silex extension 166 | * Added a RouterAwareFactory 167 | * Added a helper to be able to reuse the logic more easily for other templating engines 168 | * Added a way to retrieve an item using a path in a menu tree 169 | * Changed the toArray method to use a depth instead of simply using a boolean flag 170 | * Refactored the export to array and the creation from an array 171 | * Added better support for encoding problems when escaping a string in the ListRenderer 172 | * Added a Twig renderer 173 | * Added missing escaping in the ListRenderer 174 | * Renamed some methods in the ItemInterface 175 | * Removed the configuration of the current item as link from the item 176 | * Refactored the ListRenderer to use options 177 | * Changed the interface of callRecursively 178 | * Refactored the NodeInterface to be consistent 179 | * Moved the creation of the item to the factory 180 | * Added a Twig extension to render the menu easily 181 | * Changed the menu provider interface with a pimple-based implementation 182 | * Added a renderer provider to get a renderer by name and a Pimple-based implementation 183 | * Removed the renderer from the menu 184 | * Removed the num in the item by refactoring isLast and isFirst 185 | * Changed the RendererInterface to accept an array of options to be more flexible 186 | * Added an ItemInterface 187 | * Initial import of KnpMenuBundle decoupled classes with a new namespace 188 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-present KnpLabs - https://knplabs.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "knplabs/knp-menu", 3 | "type": "library", 4 | "description": "An object oriented menu library", 5 | "keywords": ["menu", "tree"], 6 | "homepage": "https://knplabs.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "KnpLabs", 11 | "homepage": "https://knplabs.com" 12 | }, 13 | { 14 | "name": "Christophe Coevoet", 15 | "email": "stof@notk.org" 16 | }, 17 | { 18 | "name": "The Community", 19 | "homepage": "https://github.com/KnpLabs/KnpMenu/contributors" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.1" 24 | }, 25 | "conflict": { 26 | "twig/twig": "<1.42.3 || >=2,<2.9" 27 | }, 28 | "require-dev": { 29 | "phpstan/phpstan": "^1.10", 30 | "phpunit/phpunit": "^9.6", 31 | "psr/container": "^1.0 || ^2.0", 32 | "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", 33 | "symfony/phpunit-bridge": "^7.0", 34 | "symfony/routing": "^5.4 || ^6.0 || ^7.0", 35 | "twig/twig": "^2.16 || ^3.0" 36 | }, 37 | "suggest": { 38 | "twig/twig": "for the TwigRenderer and the integration with your templates" 39 | }, 40 | "autoload": { 41 | "psr-4": { "Knp\\Menu\\": "src/Knp/Menu" } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Knp\\Menu\\Tests\\": "tests/Knp/Menu/Tests" 46 | } 47 | }, 48 | "config": { 49 | "sort-packages": true 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "3.x-dev" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Knp/Menu/Factory/CoreExtension.php: -------------------------------------------------------------------------------- 1 | null, 17 | 'label' => null, 18 | 'attributes' => [], 19 | 'linkAttributes' => [], 20 | 'childrenAttributes' => [], 21 | 'labelAttributes' => [], 22 | 'extras' => [], 23 | 'current' => null, 24 | 'display' => true, 25 | 'displayChildren' => true, 26 | ], 27 | $options 28 | ); 29 | } 30 | 31 | public function buildItem(ItemInterface $item, array $options): void 32 | { 33 | $item 34 | ->setUri($options['uri']) 35 | ->setLabel($options['label']) 36 | ->setAttributes($options['attributes']) 37 | ->setLinkAttributes($options['linkAttributes']) 38 | ->setChildrenAttributes($options['childrenAttributes']) 39 | ->setLabelAttributes($options['labelAttributes']) 40 | ->setCurrent($options['current']) 41 | ->setDisplay($options['display']) 42 | ->setDisplayChildren($options['displayChildren']) 43 | ; 44 | 45 | $this->buildExtras($item, $options); 46 | } 47 | 48 | /** 49 | * Configures the newly created item's extras 50 | * Extras are processed one by one in order not to reset values set by other extensions 51 | * 52 | * @param array> $options 53 | */ 54 | private function buildExtras(ItemInterface $item, array $options): void 55 | { 56 | if (!empty($options['extras'])) { 57 | foreach ($options['extras'] as $key => $value) { 58 | $item->setExtra($key, $value); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Knp/Menu/Factory/ExtensionInterface.php: -------------------------------------------------------------------------------- 1 | $options The options processed by the previous extensions 13 | * 14 | * @return array 15 | */ 16 | public function buildOptions(array $options): array; 17 | 18 | /** 19 | * Configures the item with the passed options 20 | * 21 | * @param array $options 22 | */ 23 | public function buildItem(ItemInterface $item, array $options): void; 24 | } 25 | -------------------------------------------------------------------------------- /src/Knp/Menu/FactoryInterface.php: -------------------------------------------------------------------------------- 1 | $options 14 | */ 15 | public function createItem(string $name, array $options = []): ItemInterface; 16 | } 17 | -------------------------------------------------------------------------------- /src/Knp/Menu/Integration/Symfony/RoutingExtension.php: -------------------------------------------------------------------------------- 1 | generator->generate($options['route'], $params, $absolute); 24 | 25 | // adding the item route to the extras under the 'routes' key (for the Silex RouteVoter) 26 | $options['extras']['routes'][] = [ 27 | 'route' => $options['route'], 28 | 'parameters' => $params, 29 | ]; 30 | } 31 | 32 | return $options; 33 | } 34 | 35 | public function buildItem(ItemInterface $item, array $options): void 36 | { 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Knp/Menu/ItemInterface.php: -------------------------------------------------------------------------------- 1 | tag and is what you should interact with 9 | * most of the time by default. 10 | * Originally taken from ioMenuPlugin (http://github.com/weaverryan/ioMenuPlugin) 11 | * 12 | * @extends \ArrayAccess 13 | * @extends \IteratorAggregate 14 | */ 15 | interface ItemInterface extends \ArrayAccess, \Countable, \IteratorAggregate 16 | { 17 | public function setFactory(FactoryInterface $factory): self; 18 | 19 | public function getName(): string; 20 | 21 | /** 22 | * Renames the item. 23 | * 24 | * This method must also update the key in the parent. 25 | * 26 | * Provides a fluent interface 27 | * 28 | * @throws \InvalidArgumentException if the name is already used by a sibling 29 | */ 30 | public function setName(string $name): self; 31 | 32 | /** 33 | * Get the uri for a menu item 34 | */ 35 | public function getUri(): ?string; 36 | 37 | /** 38 | * Set the uri for a menu item 39 | * 40 | * Provides a fluent interface 41 | * 42 | * @param string|null $uri The uri to set on this menu item 43 | */ 44 | public function setUri(?string $uri): self; 45 | 46 | /** 47 | * Returns the label that will be used to render this menu item 48 | * 49 | * Defaults to the name of no label was specified 50 | */ 51 | public function getLabel(): string; 52 | 53 | /** 54 | * Provides a fluent interface 55 | * 56 | * @param string|null $label The text to use when rendering this menu item 57 | */ 58 | public function setLabel(?string $label): self; 59 | 60 | /** 61 | * @return array 62 | */ 63 | public function getAttributes(): array; 64 | 65 | /** 66 | * @param array $attributes 67 | */ 68 | public function setAttributes(array $attributes): self; 69 | 70 | /** 71 | * @param string $name The name of the attribute to return 72 | * @param string|bool|null $default The value to return if the attribute doesn't exist 73 | * 74 | * @return string|bool|null 75 | */ 76 | public function getAttribute(string $name, $default = null); 77 | 78 | /** 79 | * @param string|bool|null $value 80 | */ 81 | public function setAttribute(string $name, $value): self; 82 | 83 | /** 84 | * @return array 85 | */ 86 | public function getLinkAttributes(): array; 87 | 88 | /** 89 | * @param array $linkAttributes 90 | */ 91 | public function setLinkAttributes(array $linkAttributes): self; 92 | 93 | /** 94 | * @param string $name The name of the attribute to return 95 | * @param string|bool|null $default The value to return if the attribute doesn't exist 96 | * 97 | * @return string|bool|null 98 | */ 99 | public function getLinkAttribute(string $name, $default = null); 100 | 101 | /** 102 | * @param string|bool|null $value 103 | */ 104 | public function setLinkAttribute(string $name, $value): self; 105 | 106 | /** 107 | * @return array 108 | */ 109 | public function getChildrenAttributes(): array; 110 | 111 | /** 112 | * @param array $childrenAttributes 113 | */ 114 | public function setChildrenAttributes(array $childrenAttributes): self; 115 | 116 | /** 117 | * @param string $name The name of the attribute to return 118 | * @param string|bool|null $default The value to return if the attribute doesn't exist 119 | * 120 | * @return string|bool|null 121 | */ 122 | public function getChildrenAttribute(string $name, $default = null); 123 | 124 | /** 125 | * @param string|bool|null $value 126 | */ 127 | public function setChildrenAttribute(string $name, $value): self; 128 | 129 | /** 130 | * @return array 131 | */ 132 | public function getLabelAttributes(): array; 133 | 134 | /** 135 | * @param array $labelAttributes 136 | */ 137 | public function setLabelAttributes(array $labelAttributes): self; 138 | 139 | /** 140 | * @param string $name The name of the attribute to return 141 | * @param string|bool|null $default The value to return if the attribute doesn't exist 142 | * 143 | * @return string|bool|null 144 | */ 145 | public function getLabelAttribute(string $name, $default = null); 146 | 147 | /** 148 | * @param string|bool|null $value 149 | */ 150 | public function setLabelAttribute(string $name, $value): self; 151 | 152 | /** 153 | * @return array 154 | */ 155 | public function getExtras(): array; 156 | 157 | /** 158 | * @param array $extras 159 | */ 160 | public function setExtras(array $extras): self; 161 | 162 | /** 163 | * @param string $name The name of the extra to return 164 | * @param mixed $default The value to return if the extra doesn't exist 165 | * 166 | * @return mixed 167 | */ 168 | public function getExtra(string $name, $default = null); 169 | 170 | /** 171 | * @param mixed $value 172 | */ 173 | public function setExtra(string $name, $value): self; 174 | 175 | public function getDisplayChildren(): bool; 176 | 177 | /** 178 | * Set whether or not this menu item should show its children 179 | * 180 | * Provides a fluent interface 181 | */ 182 | public function setDisplayChildren(bool $bool): self; 183 | 184 | /** 185 | * Whether or not to display this menu item 186 | */ 187 | public function isDisplayed(): bool; 188 | 189 | /** 190 | * Set whether or not this menu should be displayed 191 | * 192 | * Provides a fluent interface 193 | */ 194 | public function setDisplay(bool $bool): self; 195 | 196 | /** 197 | * Add a child menu item to this menu 198 | * 199 | * Returns the child item 200 | * 201 | * @param ItemInterface|string $child An ItemInterface instance or the name of a new item to create 202 | * @param array $options If creating a new item, the options passed to the factory for the item 203 | * 204 | * @throws \InvalidArgumentException if the item is already in a tree 205 | */ 206 | public function addChild($child, array $options = []): self; 207 | 208 | /** 209 | * Returns the child menu identified by the given name 210 | * 211 | * @param string $name Then name of the child menu to return 212 | */ 213 | public function getChild(string $name): ?self; 214 | 215 | /** 216 | * Reorder children. 217 | * 218 | * Provides a fluent interface 219 | * 220 | * @param array $order new order of children 221 | */ 222 | public function reorderChildren(array $order): self; 223 | 224 | /** 225 | * Makes a deep copy of menu tree. Every item is copied as another object. 226 | */ 227 | public function copy(): self; 228 | 229 | /** 230 | * Returns the level of this menu item 231 | * 232 | * The root menu item is 0, followed by 1, 2, etc 233 | */ 234 | public function getLevel(): int; 235 | 236 | /** 237 | * Returns the root ItemInterface of this menu tree 238 | */ 239 | public function getRoot(): self; 240 | 241 | /** 242 | * Returns whether or not this menu item is the root menu item 243 | */ 244 | public function isRoot(): bool; 245 | 246 | public function getParent(): ?self; 247 | 248 | /** 249 | * Used internally when adding and removing children 250 | * 251 | * Provides a fluent interface 252 | */ 253 | public function setParent(?self $parent = null): self; 254 | 255 | /** 256 | * Return the children as an array of ItemInterface objects 257 | * 258 | * @return array 259 | */ 260 | public function getChildren(): array; 261 | 262 | /** 263 | * Provides a fluent interface 264 | * 265 | * @param array $children An array of ItemInterface objects 266 | */ 267 | public function setChildren(array $children): self; 268 | 269 | /** 270 | * Removes a child from this menu item 271 | * 272 | * Provides a fluent interface 273 | * 274 | * @param ItemInterface|string $name The name of ItemInterface instance or the ItemInterface to remove 275 | */ 276 | public function removeChild($name): self; 277 | 278 | public function getFirstChild(): self; 279 | 280 | public function getLastChild(): self; 281 | 282 | /** 283 | * Returns whether or not this menu items has viewable children 284 | * 285 | * This menu MAY have children, but this will return false if the current 286 | * user does not have access to view any of those items 287 | */ 288 | public function hasChildren(): bool; 289 | 290 | /** 291 | * Sets whether or not this menu item is "current". 292 | * 293 | * If the state is unknown, use null. 294 | * 295 | * Provides a fluent interface 296 | * 297 | * @param bool|null $bool Specify that this menu item is current 298 | */ 299 | public function setCurrent(?bool $bool): self; 300 | 301 | /** 302 | * Gets whether or not this menu item is "current". 303 | */ 304 | public function isCurrent(): ?bool; 305 | 306 | /** 307 | * Whether this menu item is last in its parent 308 | */ 309 | public function isLast(): bool; 310 | 311 | /** 312 | * Whether this menu item is first in its parent 313 | */ 314 | public function isFirst(): bool; 315 | 316 | /** 317 | * Whereas isFirst() returns if this is the first child of the parent 318 | * menu item, this function takes into consideration whether children are rendered or not. 319 | * 320 | * This returns true if this is the first child that would be rendered 321 | * for the current user 322 | */ 323 | public function actsLikeFirst(): bool; 324 | 325 | /** 326 | * Whereas isLast() returns if this is the last child of the parent 327 | * menu item, this function takes into consideration whether children are rendered or not. 328 | * 329 | * This returns true if this is the last child that would be rendered 330 | * for the current user 331 | */ 332 | public function actsLikeLast(): bool; 333 | } 334 | -------------------------------------------------------------------------------- /src/Knp/Menu/Iterator/CurrentItemFilterIterator.php: -------------------------------------------------------------------------------- 1 | $iterator 14 | */ 15 | public function __construct(\Iterator $iterator, private MatcherInterface $matcher) 16 | { 17 | 18 | parent::__construct($iterator); 19 | } 20 | 21 | /** 22 | * @return bool 23 | */ 24 | #[\ReturnTypeWillChange] 25 | public function accept() 26 | { 27 | return $this->matcher->isCurrent($this->current()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Knp/Menu/Iterator/DisplayedItemFilterIterator.php: -------------------------------------------------------------------------------- 1 | current()->isDisplayed(); 17 | } 18 | 19 | /** 20 | * @return bool 21 | */ 22 | #[\ReturnTypeWillChange] 23 | public function hasChildren() 24 | { 25 | return $this->current()->getDisplayChildren() && parent::hasChildren(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Knp/Menu/Iterator/RecursiveItemIterator.php: -------------------------------------------------------------------------------- 1 | > 9 | * 10 | * @implements \RecursiveIterator> 11 | */ 12 | class RecursiveItemIterator extends \IteratorIterator implements \RecursiveIterator 13 | { 14 | /** 15 | * @param \Traversable $iterator 16 | */ 17 | final public function __construct(\Traversable $iterator) 18 | { 19 | parent::__construct($iterator); 20 | } 21 | 22 | public function hasChildren(): bool 23 | { 24 | return 0 < \count($this->current()); 25 | } 26 | 27 | /** 28 | * @return RecursiveItemIterator 29 | */ 30 | #[\ReturnTypeWillChange] 31 | public function getChildren() 32 | { 33 | return new static($this->current()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Knp/Menu/Loader/ArrayLoader.php: -------------------------------------------------------------------------------- 1 | supports($data)) { 22 | throw new \InvalidArgumentException(\sprintf('Unsupported data. Expected an array but got %s', \get_debug_type($data))); 23 | } 24 | 25 | return $this->fromArray($data); 26 | } 27 | 28 | public function supports($data): bool 29 | { 30 | return \is_array($data); 31 | } 32 | 33 | /** 34 | * @param array $data 35 | * @param string|null $name (the name of the item, used only if there is no name in the data themselves) 36 | */ 37 | private function fromArray(array $data, ?string $name = null): ItemInterface 38 | { 39 | $name = $data['name'] ?? $name; 40 | 41 | if (isset($data['children'])) { 42 | $children = $data['children']; 43 | unset($data['children']); 44 | } else { 45 | $children = []; 46 | } 47 | 48 | $item = $this->factory->createItem($name, $data); 49 | 50 | foreach ($children as $childName => $child) { 51 | $item->addChild($this->fromArray($child, $childName)); 52 | } 53 | 54 | return $item; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Knp/Menu/Loader/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | factory->createItem($data->getName(), $data->getOptions()); 22 | 23 | foreach ($data->getChildren() as $childNode) { 24 | $item->addChild($this->load($childNode)); 25 | } 26 | 27 | return $item; 28 | } 29 | 30 | public function supports($data): bool 31 | { 32 | return $data instanceof NodeInterface; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/Matcher.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private \SplObjectStorage $cache; 17 | 18 | /** 19 | * @var iterable|VoterInterface[] 20 | */ 21 | private iterable $voters; 22 | 23 | /** 24 | * @param VoterInterface[]|iterable $voters 25 | */ 26 | public function __construct($voters = []) 27 | { 28 | $this->voters = $voters; 29 | $this->cache = new \SplObjectStorage(); 30 | } 31 | 32 | public function isCurrent(ItemInterface $item): bool 33 | { 34 | $current = $item->isCurrent(); 35 | if (null !== $current) { 36 | return $current; 37 | } 38 | 39 | if ($this->cache->contains($item)) { 40 | return $this->cache[$item]; 41 | } 42 | 43 | foreach ($this->voters as $voter) { 44 | $current = $voter->matchItem($item); 45 | if (null !== $current) { 46 | break; 47 | } 48 | } 49 | 50 | $current = (bool) $current; 51 | $this->cache[$item] = $current; 52 | 53 | return $current; 54 | } 55 | 56 | public function isAncestor(ItemInterface $item, ?int $depth = null): bool 57 | { 58 | if (0 === $depth) { 59 | return false; 60 | } 61 | 62 | $childDepth = null === $depth ? null : $depth - 1; 63 | foreach ($item->getChildren() as $child) { 64 | if ($this->isCurrent($child) || $this->isAncestor($child, $childDepth)) { 65 | return true; 66 | } 67 | } 68 | 69 | return false; 70 | } 71 | 72 | public function clear(): void 73 | { 74 | $this->cache = new \SplObjectStorage(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/MatcherInterface.php: -------------------------------------------------------------------------------- 1 | getExtra('match_callback'); 15 | 16 | if (null === $callback) { 17 | return null; 18 | } 19 | 20 | if (!\is_callable($callback)) { 21 | throw new \InvalidArgumentException('Extra "match_callback" must be callable.'); 22 | } 23 | 24 | return $callback(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/Voter/RegexVoter.php: -------------------------------------------------------------------------------- 1 | regexp || null === $item->getUri()) { 20 | return null; 21 | } 22 | 23 | if (\preg_match($this->regexp, $item->getUri())) { 24 | return true; 25 | } 26 | 27 | return null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/Voter/RouteVoter.php: -------------------------------------------------------------------------------- 1 | requestStack, 'getMainRequest')) { 21 | $request = $this->requestStack->getMainRequest(); // symfony 5.3+ 22 | } else { 23 | $request = $this->requestStack->getMasterRequest(); 24 | } 25 | 26 | if (null === $request) { 27 | return null; 28 | } 29 | 30 | $route = $request->attributes->get('_route'); 31 | if (null === $route) { 32 | return null; 33 | } 34 | 35 | $routes = (array) $item->getExtra('routes', []); 36 | 37 | foreach ($routes as $testedRoute) { 38 | if (\is_string($testedRoute)) { 39 | $testedRoute = ['route' => $testedRoute]; 40 | } 41 | 42 | if (!\is_array($testedRoute)) { 43 | throw new \InvalidArgumentException('Routes extra items must be strings or arrays.'); 44 | } 45 | 46 | if ($this->isMatchingRoute($request, $testedRoute)) { 47 | return true; 48 | } 49 | } 50 | 51 | return null; 52 | } 53 | 54 | /** 55 | * @phpstan-param array{route?: string|null, pattern?: string|null, parameters?: array, query_parameters?: array} $testedRoute 56 | */ 57 | private function isMatchingRoute(Request $request, array $testedRoute): bool 58 | { 59 | $route = $request->attributes->get('_route'); 60 | 61 | if (isset($testedRoute['route'])) { 62 | if ($route !== $testedRoute['route']) { 63 | return false; 64 | } 65 | } elseif (!empty($testedRoute['pattern'])) { 66 | if (!\preg_match($testedRoute['pattern'], $route)) { 67 | return false; 68 | } 69 | } else { 70 | throw new \InvalidArgumentException('Routes extra items must have a "route" or "pattern" key.'); 71 | } 72 | 73 | return $this->isMatchingParameters($request, $testedRoute) && $this->isMatchingQueryParameters($request, $testedRoute); 74 | } 75 | 76 | /** 77 | * @phpstan-param array{route?: string|null, pattern?: string|null, parameters?: array, query_parameters?: array} $testedRoute 78 | */ 79 | private function isMatchingParameters(Request $request, array $testedRoute): bool 80 | { 81 | if (!isset($testedRoute['parameters'])) { 82 | return true; 83 | } 84 | 85 | $routeParameters = $request->attributes->get('_route_params', []); 86 | 87 | foreach ($testedRoute['parameters'] as $name => $value) { 88 | // cast both to string so that we handle integer and other non-string parameters, but don't stumble on 0 == 'abc'. 89 | if (!isset($routeParameters[$name]) || (string) $routeParameters[$name] !== (string) $value) { 90 | return false; 91 | } 92 | } 93 | 94 | return true; 95 | } 96 | 97 | /** 98 | * @phpstan-param array{route?: string|null, pattern?: string|null, parameters?: array, query_parameters?: array} $testedRoute 99 | */ 100 | private function isMatchingQueryParameters(Request $request, array $testedRoute): bool 101 | { 102 | if (!isset($testedRoute['query_parameters'])) { 103 | return true; 104 | } 105 | 106 | $routeQueryParameters = $request->query->all(); 107 | 108 | foreach ($testedRoute['query_parameters'] as $name => $value) { 109 | // cast both to string so that we handle integer and other non-string parameters, but don't stumble on 0 == 'abc'. 110 | if (!isset($routeQueryParameters[$name]) || \is_array($routeQueryParameters[$name]) || (string) $routeQueryParameters[$name] !== (string) $value) { 111 | return false; 112 | } 113 | } 114 | 115 | return true; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/Voter/UriVoter.php: -------------------------------------------------------------------------------- 1 | uri || null === $item->getUri()) { 19 | return null; 20 | } 21 | 22 | if ($item->getUri() === $this->uri) { 23 | return true; 24 | } 25 | 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Knp/Menu/Matcher/Voter/VoterInterface.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | private array $extensions = []; 17 | 18 | /** 19 | * @var ExtensionInterface[]|null 20 | */ 21 | private ?array $sorted = null; 22 | 23 | public function __construct() 24 | { 25 | $this->addExtension(new CoreExtension(), -10); 26 | } 27 | 28 | public function createItem(string $name, array $options = []): ItemInterface 29 | { 30 | foreach ($this->getExtensions() as $extension) { 31 | $options = $extension->buildOptions($options); 32 | } 33 | 34 | $item = new MenuItem($name, $this); 35 | 36 | foreach ($this->getExtensions() as $extension) { 37 | $extension->buildItem($item, $options); 38 | } 39 | 40 | return $item; 41 | } 42 | 43 | /** 44 | * Adds a factory extension 45 | */ 46 | public function addExtension(ExtensionInterface $extension, int $priority = 0): void 47 | { 48 | $this->extensions[$priority][] = $extension; 49 | $this->sorted = null; 50 | } 51 | 52 | /** 53 | * Sorts the internal list of extensions by priority. 54 | * 55 | * @return ExtensionInterface[] 56 | */ 57 | private function getExtensions(): array 58 | { 59 | if (null === $this->sorted) { 60 | \krsort($this->extensions); 61 | $this->sorted = !empty($this->extensions) ? \array_merge(...$this->extensions) : []; 62 | } 63 | 64 | return $this->sorted; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Knp/Menu/MenuItem.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | protected $linkAttributes = []; 30 | 31 | /** 32 | * Attributes for the children list 33 | * 34 | * @var array 35 | */ 36 | protected $childrenAttributes = []; 37 | 38 | /** 39 | * Attributes for the item text 40 | * 41 | * @var array 42 | */ 43 | protected $labelAttributes = []; 44 | 45 | /** 46 | * Uri to use in the anchor tag 47 | * 48 | * @var string|null 49 | */ 50 | protected $uri; 51 | 52 | /** 53 | * Attributes for the item 54 | * 55 | * @var array 56 | */ 57 | protected $attributes = []; 58 | 59 | /** 60 | * Extra stuff associated to the item 61 | * 62 | * @var array 63 | */ 64 | protected $extras = []; 65 | 66 | /** 67 | * Whether the item is displayed 68 | * 69 | * @var bool 70 | */ 71 | protected $display = true; 72 | 73 | /** 74 | * Whether the children of the item are displayed 75 | * 76 | * @var bool 77 | */ 78 | protected $displayChildren = true; 79 | 80 | /** 81 | * Child items 82 | * 83 | * @var array 84 | */ 85 | protected $children = []; 86 | 87 | /** 88 | * Parent item 89 | * 90 | * @var ItemInterface|null 91 | */ 92 | protected $parent; 93 | 94 | /** 95 | * whether the item is current. null means unknown 96 | * 97 | * @var bool|null 98 | */ 99 | protected $isCurrent; 100 | 101 | /** 102 | * @var FactoryInterface 103 | */ 104 | protected $factory; 105 | 106 | /** 107 | * Class constructor 108 | * 109 | * @param string $name The name of this menu, which is how its parent will 110 | * reference it. Also used as label if label not specified 111 | */ 112 | public function __construct(string $name, FactoryInterface $factory) 113 | { 114 | $this->name = $name; 115 | $this->factory = $factory; 116 | } 117 | 118 | public function setFactory(FactoryInterface $factory): ItemInterface 119 | { 120 | $this->factory = $factory; 121 | 122 | return $this; 123 | } 124 | 125 | public function getName(): string 126 | { 127 | return $this->name; 128 | } 129 | 130 | public function setName(string $name): ItemInterface 131 | { 132 | if ($this->name === $name) { 133 | return $this; 134 | } 135 | 136 | $parent = $this->getParent(); 137 | if (null !== $parent && isset($parent[$name])) { 138 | throw new \InvalidArgumentException('Cannot rename item, name is already used by sibling.'); 139 | } 140 | 141 | $oldName = $this->name; 142 | $this->name = $name; 143 | 144 | if (null !== $parent) { 145 | $names = \array_keys($parent->getChildren()); 146 | $items = \array_values($parent->getChildren()); 147 | 148 | $offset = \array_search($oldName, $names); 149 | $names[$offset] = $name; 150 | 151 | if (false === $children = \array_combine($names, $items)) { 152 | throw new \InvalidArgumentException('Number of elements is not matching.'); 153 | } 154 | 155 | $parent->setChildren($children); 156 | } 157 | 158 | return $this; 159 | } 160 | 161 | public function getUri(): ?string 162 | { 163 | return $this->uri; 164 | } 165 | 166 | public function setUri(?string $uri): ItemInterface 167 | { 168 | $this->uri = $uri; 169 | 170 | return $this; 171 | } 172 | 173 | public function getLabel(): string 174 | { 175 | return $this->label ?? $this->name; 176 | } 177 | 178 | public function setLabel(?string $label): ItemInterface 179 | { 180 | $this->label = $label; 181 | 182 | return $this; 183 | } 184 | 185 | public function getAttributes(): array 186 | { 187 | return $this->attributes; 188 | } 189 | 190 | public function setAttributes(array $attributes): ItemInterface 191 | { 192 | $this->attributes = $attributes; 193 | 194 | return $this; 195 | } 196 | 197 | public function getAttribute(string $name, $default = null) 198 | { 199 | return $this->attributes[$name] ?? $default; 200 | } 201 | 202 | public function setAttribute(string $name, $value): ItemInterface 203 | { 204 | $this->attributes[$name] = $value; 205 | 206 | return $this; 207 | } 208 | 209 | public function getLinkAttributes(): array 210 | { 211 | return $this->linkAttributes; 212 | } 213 | 214 | public function setLinkAttributes(array $linkAttributes): ItemInterface 215 | { 216 | $this->linkAttributes = $linkAttributes; 217 | 218 | return $this; 219 | } 220 | 221 | public function getLinkAttribute(string $name, $default = null) 222 | { 223 | return $this->linkAttributes[$name] ?? $default; 224 | } 225 | 226 | public function setLinkAttribute(string $name, $value): ItemInterface 227 | { 228 | $this->linkAttributes[$name] = $value; 229 | 230 | return $this; 231 | } 232 | 233 | public function getChildrenAttributes(): array 234 | { 235 | return $this->childrenAttributes; 236 | } 237 | 238 | public function setChildrenAttributes(array $childrenAttributes): ItemInterface 239 | { 240 | $this->childrenAttributes = $childrenAttributes; 241 | 242 | return $this; 243 | } 244 | 245 | public function getChildrenAttribute(string $name, $default = null) 246 | { 247 | return $this->childrenAttributes[$name] ?? $default; 248 | } 249 | 250 | public function setChildrenAttribute(string $name, $value): ItemInterface 251 | { 252 | $this->childrenAttributes[$name] = $value; 253 | 254 | return $this; 255 | } 256 | 257 | public function getLabelAttributes(): array 258 | { 259 | return $this->labelAttributes; 260 | } 261 | 262 | public function setLabelAttributes(array $labelAttributes): ItemInterface 263 | { 264 | $this->labelAttributes = $labelAttributes; 265 | 266 | return $this; 267 | } 268 | 269 | public function getLabelAttribute(string $name, $default = null) 270 | { 271 | return $this->labelAttributes[$name] ?? $default; 272 | } 273 | 274 | public function setLabelAttribute(string $name, $value): ItemInterface 275 | { 276 | $this->labelAttributes[$name] = $value; 277 | 278 | return $this; 279 | } 280 | 281 | public function getExtras(): array 282 | { 283 | return $this->extras; 284 | } 285 | 286 | public function setExtras(array $extras): ItemInterface 287 | { 288 | $this->extras = $extras; 289 | 290 | return $this; 291 | } 292 | 293 | public function getExtra(string $name, $default = null) 294 | { 295 | return $this->extras[$name] ?? $default; 296 | } 297 | 298 | public function setExtra(string $name, $value): ItemInterface 299 | { 300 | $this->extras[$name] = $value; 301 | 302 | return $this; 303 | } 304 | 305 | public function getDisplayChildren(): bool 306 | { 307 | return $this->displayChildren; 308 | } 309 | 310 | public function setDisplayChildren(bool $bool): ItemInterface 311 | { 312 | $this->displayChildren = $bool; 313 | 314 | return $this; 315 | } 316 | 317 | public function isDisplayed(): bool 318 | { 319 | return $this->display; 320 | } 321 | 322 | public function setDisplay(bool $bool): ItemInterface 323 | { 324 | $this->display = $bool; 325 | 326 | return $this; 327 | } 328 | 329 | public function addChild($child, array $options = []): ItemInterface 330 | { 331 | if (!$child instanceof ItemInterface) { 332 | $child = $this->factory->createItem($child, $options); 333 | } elseif (null !== $child->getParent()) { 334 | throw new \InvalidArgumentException('Cannot add menu item as child, it already belongs to another menu (e.g. has a parent).'); 335 | } 336 | 337 | $child->setParent($this); 338 | 339 | $this->children[$child->getName()] = $child; 340 | 341 | return $child; 342 | } 343 | 344 | public function getChild(string $name): ?ItemInterface 345 | { 346 | return $this->children[$name] ?? null; 347 | } 348 | 349 | public function reorderChildren(array $order): ItemInterface 350 | { 351 | if (\count($order) !== $this->count()) { 352 | throw new \InvalidArgumentException('Cannot reorder children, order does not contain all children.'); 353 | } 354 | 355 | $newChildren = []; 356 | 357 | foreach ($order as $name) { 358 | if (!isset($this->children[$name])) { 359 | throw new \InvalidArgumentException('Cannot find children named '.$name); 360 | } 361 | 362 | $child = $this->children[$name]; 363 | $newChildren[$name] = $child; 364 | } 365 | 366 | $this->setChildren($newChildren); 367 | 368 | return $this; 369 | } 370 | 371 | public function copy(): ItemInterface 372 | { 373 | $newMenu = clone $this; 374 | $newMenu->setChildren([]); 375 | $newMenu->setParent(); 376 | foreach ($this->getChildren() as $child) { 377 | $newMenu->addChild($child->copy()); 378 | } 379 | 380 | return $newMenu; 381 | } 382 | 383 | public function getLevel(): int 384 | { 385 | return $this->parent ? $this->parent->getLevel() + 1 : 0; 386 | } 387 | 388 | public function getRoot(): ItemInterface 389 | { 390 | $obj = $this; 391 | do { 392 | $found = $obj; 393 | } while ($obj = $obj->getParent()); 394 | 395 | return $found; 396 | } 397 | 398 | public function isRoot(): bool 399 | { 400 | return null === $this->parent; 401 | } 402 | 403 | public function getParent(): ?ItemInterface 404 | { 405 | return $this->parent; 406 | } 407 | 408 | public function setParent(?ItemInterface $parent = null): ItemInterface 409 | { 410 | if ($parent === $this) { 411 | throw new \InvalidArgumentException('Item cannot be a child of itself'); 412 | } 413 | 414 | $this->parent = $parent; 415 | 416 | return $this; 417 | } 418 | 419 | public function getChildren(): array 420 | { 421 | return $this->children; 422 | } 423 | 424 | public function setChildren(array $children): ItemInterface 425 | { 426 | $this->children = $children; 427 | 428 | return $this; 429 | } 430 | 431 | public function removeChild($name): ItemInterface 432 | { 433 | $name = $name instanceof ItemInterface ? $name->getName() : $name; 434 | 435 | if (isset($this->children[$name])) { 436 | // unset the child and reset it so it looks independent 437 | $this->children[$name]->setParent(null); 438 | unset($this->children[$name]); 439 | } 440 | 441 | return $this; 442 | } 443 | 444 | public function getFirstChild(): ItemInterface 445 | { 446 | if (empty($this->children)) { 447 | throw new \LogicException('Cannot get first child: there are no children.'); 448 | } 449 | 450 | return \reset($this->children); 451 | } 452 | 453 | public function getLastChild(): ItemInterface 454 | { 455 | if (empty($this->children)) { 456 | throw new \LogicException('Cannot get last child: there are no children.'); 457 | } 458 | 459 | return \end($this->children); 460 | } 461 | 462 | public function hasChildren(): bool 463 | { 464 | foreach ($this->children as $child) { 465 | if ($child->isDisplayed()) { 466 | return true; 467 | } 468 | } 469 | 470 | return false; 471 | } 472 | 473 | public function setCurrent(?bool $bool): ItemInterface 474 | { 475 | $this->isCurrent = $bool; 476 | 477 | return $this; 478 | } 479 | 480 | public function isCurrent(): ?bool 481 | { 482 | return $this->isCurrent; 483 | } 484 | 485 | public function isLast(): bool 486 | { 487 | // if this is root, then return false 488 | if (null === $this->parent) { 489 | return false; 490 | } 491 | 492 | return $this->parent->getLastChild() === $this; 493 | } 494 | 495 | public function isFirst(): bool 496 | { 497 | // if this is root, then return false 498 | if (null === $this->parent) { 499 | return false; 500 | } 501 | 502 | return $this->parent->getFirstChild() === $this; 503 | } 504 | 505 | public function actsLikeFirst(): bool 506 | { 507 | // root items are never "marked" as first 508 | if (null === $this->parent) { 509 | return false; 510 | } 511 | 512 | // A menu acts like first only if it is displayed 513 | if (!$this->isDisplayed()) { 514 | return false; 515 | } 516 | 517 | // if we're first and visible, we're first, period. 518 | if ($this->isFirst()) { 519 | return true; 520 | } 521 | 522 | $children = $this->parent->getChildren(); 523 | foreach ($children as $child) { 524 | // loop until we find a visible menu. If its this menu, we're first 525 | if ($child->isDisplayed()) { 526 | return $child->getName() === $this->getName(); 527 | } 528 | } 529 | 530 | return false; 531 | } 532 | 533 | public function actsLikeLast(): bool 534 | { 535 | // root items are never "marked" as last 536 | if (null === $this->parent) { 537 | return false; 538 | } 539 | 540 | // A menu acts like last only if it is displayed 541 | if (!$this->isDisplayed()) { 542 | return false; 543 | } 544 | 545 | // if we're last and visible, we're last, period. 546 | if ($this->isLast()) { 547 | return true; 548 | } 549 | 550 | $children = \array_reverse($this->parent->getChildren()); 551 | foreach ($children as $child) { 552 | // loop until we find a visible menu. If its this menu, we're first 553 | if ($child->isDisplayed()) { 554 | return $child->getName() === $this->getName(); 555 | } 556 | } 557 | 558 | return false; 559 | } 560 | 561 | /** 562 | * Implements Countable 563 | */ 564 | public function count(): int 565 | { 566 | return \count($this->children); 567 | } 568 | 569 | /** 570 | * Implements IteratorAggregate 571 | */ 572 | public function getIterator(): \Traversable 573 | { 574 | return new \ArrayIterator($this->children); 575 | } 576 | 577 | /** 578 | * Implements ArrayAccess 579 | * 580 | * @param string $offset 581 | */ 582 | public function offsetExists($offset): bool 583 | { 584 | return isset($this->children[$offset]); 585 | } 586 | 587 | /** 588 | * Implements ArrayAccess 589 | * 590 | * @param string $offset 591 | * 592 | * @return ItemInterface|null 593 | */ 594 | #[\ReturnTypeWillChange] 595 | public function offsetGet($offset) 596 | { 597 | return $this->getChild($offset); 598 | } 599 | 600 | /** 601 | * Implements ArrayAccess 602 | * 603 | * @param string $offset 604 | * @param string|null $value 605 | */ 606 | public function offsetSet($offset, $value): void 607 | { 608 | $this->addChild($offset)->setLabel($value); 609 | } 610 | 611 | /** 612 | * Implements ArrayAccess 613 | * 614 | * @param string $offset 615 | */ 616 | public function offsetUnset($offset): void 617 | { 618 | $this->removeChild($offset); 619 | } 620 | } 621 | -------------------------------------------------------------------------------- /src/Knp/Menu/NodeInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function getOptions(): array; 23 | 24 | /** 25 | * Get the child nodes implementing NodeInterface 26 | * 27 | * @return \Traversable 28 | */ 29 | public function getChildren(): \Traversable; 30 | } 31 | -------------------------------------------------------------------------------- /src/Knp/Menu/Provider/ArrayAccessProvider.php: -------------------------------------------------------------------------------- 1 | $registry 18 | * @param array $menuIds The map between menu identifiers and registry keys 19 | */ 20 | public function __construct(private \ArrayAccess $registry, private array $menuIds = []) 21 | { 22 | } 23 | 24 | public function get(string $name, array $options = []): ItemInterface 25 | { 26 | if (!isset($this->menuIds[$name])) { 27 | throw new \InvalidArgumentException(\sprintf('The menu "%s" is not defined.', $name)); 28 | } 29 | 30 | $menu = $this->registry[$this->menuIds[$name]]; 31 | 32 | if (\is_callable($menu)) { 33 | $menu = $menu($options, $this->registry); 34 | } 35 | 36 | return $menu; 37 | } 38 | 39 | public function has(string $name, array $options = []): bool 40 | { 41 | return isset($this->menuIds[$name]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Knp/Menu/Provider/ChainProvider.php: -------------------------------------------------------------------------------- 1 | providers = $providers; 20 | } 21 | 22 | public function get(string $name, array $options = []): ItemInterface 23 | { 24 | foreach ($this->providers as $provider) { 25 | if ($provider->has($name, $options)) { 26 | return $provider->get($name, $options); 27 | } 28 | } 29 | 30 | throw new \InvalidArgumentException(\sprintf('The menu "%s" is not defined.', $name)); 31 | } 32 | 33 | public function has(string $name, array $options = []): bool 34 | { 35 | foreach ($this->providers as $provider) { 36 | if ($provider->has($name, $options)) { 37 | return true; 38 | } 39 | } 40 | 41 | return false; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Knp/Menu/Provider/LazyProvider.php: -------------------------------------------------------------------------------- 1 | $builders 18 | */ 19 | public function __construct(private array $builders) 20 | { 21 | } 22 | 23 | public function get(string $name, array $options = []): ItemInterface 24 | { 25 | if (!isset($this->builders[$name])) { 26 | throw new \InvalidArgumentException(\sprintf('The menu "%s" is not defined.', $name)); 27 | } 28 | 29 | $builder = $this->builders[$name]; 30 | 31 | if (\is_array($builder) && isset($builder[0]) && $builder[0] instanceof \Closure) { 32 | $builder[0] = $builder[0](); 33 | } 34 | 35 | if (!\is_callable($builder)) { 36 | throw new \LogicException(\sprintf('Invalid menu builder for "%s". A callable or a factory for an object callable are expected.', $name)); 37 | } 38 | 39 | return $builder($options); 40 | } 41 | 42 | public function has(string $name, array $options = []): bool 43 | { 44 | return isset($this->builders[$name]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Knp/Menu/Provider/MenuProviderInterface.php: -------------------------------------------------------------------------------- 1 | $options 13 | * 14 | * @throws \InvalidArgumentException if the menu does not exists 15 | */ 16 | public function get(string $name, array $options = []): ItemInterface; 17 | 18 | /** 19 | * Checks whether a menu exists in this provider 20 | * 21 | * @param array $options 22 | */ 23 | public function has(string $name, array $options = []): bool; 24 | } 25 | -------------------------------------------------------------------------------- /src/Knp/Menu/Provider/PsrProvider.php: -------------------------------------------------------------------------------- 1 | container->has($name)) { 23 | throw new \InvalidArgumentException(\sprintf('The menu "%s" is not defined.', $name)); 24 | } 25 | 26 | return $this->container->get($name); 27 | } 28 | 29 | public function has(string $name, array $options = []): bool 30 | { 31 | return $this->container->has($name); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/ArrayAccessProvider.php: -------------------------------------------------------------------------------- 1 | $registry 12 | * @param string $defaultRenderer The name of the renderer used by default 13 | * @param array $rendererIds The map between renderer names and registry keys 14 | */ 15 | public function __construct(private \ArrayAccess $registry, private string $defaultRenderer, private array $rendererIds) 16 | { 17 | } 18 | 19 | public function get(?string $name = null): RendererInterface 20 | { 21 | if (null === $name) { 22 | $name = $this->defaultRenderer; 23 | } 24 | 25 | if (!isset($this->rendererIds[$name]) || null === $this->registry[$this->rendererIds[$name]]) { 26 | throw new \InvalidArgumentException(\sprintf('The renderer "%s" is not defined.', $name)); 27 | } 28 | 29 | return $this->registry[$this->rendererIds[$name]]; 30 | } 31 | 32 | public function has(string $name): bool 33 | { 34 | return isset($this->rendererIds[$name]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/ListRenderer.php: -------------------------------------------------------------------------------- 1 | $defaultOptions 15 | */ 16 | public function __construct(protected MatcherInterface $matcher, protected array $defaultOptions = [], ?string $charset = null) 17 | { 18 | $this->defaultOptions = \array_merge([ 19 | 'depth' => null, 20 | 'matchingDepth' => null, 21 | 'currentAsLink' => true, 22 | 'currentClass' => 'current', 23 | 'ancestorClass' => 'current_ancestor', 24 | 'firstClass' => 'first', 25 | 'lastClass' => 'last', 26 | 'compressed' => false, 27 | 'allow_safe_labels' => false, 28 | 'clear_matcher' => true, 29 | 'leaf_class' => null, 30 | 'branch_class' => null, 31 | ], $defaultOptions); 32 | 33 | parent::__construct($charset); 34 | } 35 | 36 | public function render(ItemInterface $item, array $options = []): string 37 | { 38 | $options = \array_merge($this->defaultOptions, $options); 39 | 40 | $html = $this->renderList($item, $item->getChildrenAttributes(), $options); 41 | 42 | if ($options['clear_matcher']) { 43 | $this->matcher->clear(); 44 | } 45 | 46 | return $html; 47 | } 48 | 49 | /** 50 | * @param array $attributes 51 | * @param array $options 52 | */ 53 | protected function renderList(ItemInterface $item, array $attributes, array $options): string 54 | { 55 | /* 56 | * Return an empty string if any of the following are true: 57 | * a) The menu has no children eligible to be displayed 58 | * b) The depth is 0 59 | * c) This menu item has been explicitly set to hide its children 60 | */ 61 | if (0 === $options['depth'] || !$item->hasChildren() || !$item->getDisplayChildren()) { 62 | return ''; 63 | } 64 | 65 | $html = $this->format('renderHtmlAttributes($attributes).'>', 'ul', $item->getLevel(), $options); 66 | $html .= $this->renderChildren($item, $options); 67 | $html .= $this->format('', 'ul', $item->getLevel(), $options); 68 | 69 | return $html; 70 | } 71 | 72 | /** 73 | * Renders all of the children of this menu. 74 | * 75 | * This calls ->renderItem() on each menu item, which instructs each 76 | * menu item to render themselves as an
  • tag (with nested ul if it 77 | * has children). 78 | * This method updates the depth for the children. 79 | * 80 | * @param array $options the options to render the item 81 | */ 82 | protected function renderChildren(ItemInterface $item, array $options): string 83 | { 84 | // render children with a depth - 1 85 | if (null !== $options['depth']) { 86 | --$options['depth']; 87 | } 88 | 89 | if (null !== $options['matchingDepth'] && $options['matchingDepth'] > 0) { 90 | --$options['matchingDepth']; 91 | } 92 | 93 | $html = ''; 94 | foreach ($item->getChildren() as $child) { 95 | $html .= $this->renderItem($child, $options); 96 | } 97 | 98 | return $html; 99 | } 100 | 101 | /** 102 | * Called by the parent menu item to render this menu. 103 | * 104 | * This renders the li tag to fit into the parent ul as well as its 105 | * own nested ul tag if this menu item has children 106 | * 107 | * @param array $options The options to render the item 108 | */ 109 | protected function renderItem(ItemInterface $item, array $options): string 110 | { 111 | // if we don't have access or this item is marked to not be shown 112 | if (!$item->isDisplayed()) { 113 | return ''; 114 | } 115 | 116 | // create an array than can be imploded as a class list 117 | $class = (array) $item->getAttribute('class'); 118 | 119 | if ($this->matcher->isCurrent($item)) { 120 | $class[] = $options['currentClass']; 121 | } elseif ($this->matcher->isAncestor($item, $options['matchingDepth'])) { 122 | $class[] = $options['ancestorClass']; 123 | } 124 | 125 | if ($item->actsLikeFirst()) { 126 | $class[] = $options['firstClass']; 127 | } 128 | if ($item->actsLikeLast()) { 129 | $class[] = $options['lastClass']; 130 | } 131 | 132 | if (0 !== $options['depth'] && $item->hasChildren()) { 133 | if (null !== $options['branch_class'] && $item->getDisplayChildren()) { 134 | $class[] = $options['branch_class']; 135 | } 136 | } elseif (null !== $options['leaf_class']) { 137 | $class[] = $options['leaf_class']; 138 | } 139 | 140 | // retrieve the attributes and put the final class string back on it 141 | $attributes = $item->getAttributes(); 142 | if (!empty($class)) { 143 | $attributes['class'] = \implode(' ', $class); 144 | } 145 | 146 | // opening li tag 147 | $html = $this->format('renderHtmlAttributes($attributes).'>', 'li', $item->getLevel(), $options); 148 | 149 | // render the text/link inside the li tag 150 | // $html .= $this->format($item->getUri() ? $item->renderLink() : $item->renderLabel(), 'link', $item->getLevel()); 151 | $html .= $this->renderLink($item, $options); 152 | 153 | // renders the embedded ul 154 | $childrenClass = (array) $item->getChildrenAttribute('class'); 155 | $childrenClass[] = 'menu_level_'.$item->getLevel(); 156 | 157 | $childrenAttributes = $item->getChildrenAttributes(); 158 | $childrenAttributes['class'] = \implode(' ', $childrenClass); 159 | 160 | $html .= $this->renderList($item, $childrenAttributes, $options); 161 | 162 | // closing li tag 163 | $html .= $this->format('
  • ', 'li', $item->getLevel(), $options); 164 | 165 | return $html; 166 | } 167 | 168 | /** 169 | * Renders the link in a a tag with link attributes or 170 | * the label in a span tag with label attributes 171 | * 172 | * Tests if item has a an uri and if not tests if it's 173 | * the current item and if the text has to be rendered 174 | * as a link or not. 175 | * 176 | * @param ItemInterface $item The item to render the link or label for 177 | * @param array $options The options to render the item 178 | */ 179 | protected function renderLink(ItemInterface $item, array $options = []): string 180 | { 181 | if (null !== $item->getUri() && (!$this->matcher->isCurrent($item) || $options['currentAsLink'])) { 182 | $text = $this->renderLinkElement($item, $options); 183 | } else { 184 | $text = $this->renderSpanElement($item, $options); 185 | } 186 | 187 | return $this->format($text, 'link', $item->getLevel(), $options); 188 | } 189 | 190 | /** 191 | * @param array $options 192 | */ 193 | protected function renderLinkElement(ItemInterface $item, array $options): string 194 | { 195 | \assert(null !== $item->getUri()); 196 | 197 | return \sprintf('%s', $this->escape($item->getUri()), $this->renderHtmlAttributes($item->getLinkAttributes()), $this->renderLabel($item, $options)); 198 | } 199 | 200 | /** 201 | * @param array $options 202 | */ 203 | protected function renderSpanElement(ItemInterface $item, array $options): string 204 | { 205 | return \sprintf('%s', $this->renderHtmlAttributes($item->getLabelAttributes()), $this->renderLabel($item, $options)); 206 | } 207 | 208 | /** 209 | * @param array $options 210 | */ 211 | protected function renderLabel(ItemInterface $item, array $options): string 212 | { 213 | if ($options['allow_safe_labels'] && $item->getExtra('safe_label', false)) { 214 | return $item->getLabel(); 215 | } 216 | 217 | return $this->escape($item->getLabel()); 218 | } 219 | 220 | /** 221 | * If $this->renderCompressed is on, this will apply the necessary 222 | * spacing and line-breaking so that the particular thing being rendered 223 | * makes up its part in a fully-rendered and spaced menu. 224 | * 225 | * @param string $html The html to render in an (un)formatted way 226 | * @param string $type The type [ul,link,li] of thing being rendered 227 | * @param array $options 228 | */ 229 | protected function format(string $html, string $type, int $level, array $options): string 230 | { 231 | if ($options['compressed']) { 232 | return $html; 233 | } 234 | 235 | $spacing = 0; 236 | 237 | switch ($type) { 238 | case 'ul': 239 | case 'link': 240 | $spacing = $level * 4; 241 | break; 242 | 243 | case 'li': 244 | $spacing = $level * 4 - 2; 245 | } 246 | 247 | return \str_repeat(' ', $spacing).$html."\n"; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/PsrProvider.php: -------------------------------------------------------------------------------- 1 | defaultRenderer; 26 | } 27 | 28 | if (!$this->container->has($name)) { 29 | throw new \InvalidArgumentException(\sprintf('The renderer "%s" is not defined.', $name)); 30 | } 31 | 32 | return $this->container->get($name); 33 | } 34 | 35 | public function has(string $name): bool 36 | { 37 | return $this->container->has($name); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/Renderer.php: -------------------------------------------------------------------------------- 1 | charset = $charset; 13 | } 14 | } 15 | 16 | /** 17 | * Renders a HTML attribute 18 | * 19 | * @param string|bool $value 20 | */ 21 | protected function renderHtmlAttribute(string $name, $value): string 22 | { 23 | if (true === $value) { 24 | return \sprintf('%s="%s"', $name, $this->escape($name)); 25 | } 26 | if (false === $value) { 27 | throw new \InvalidArgumentException('Value cannot be false.'); 28 | } 29 | 30 | return \sprintf('%s="%s"', $name, $this->escape($value)); 31 | } 32 | 33 | /** 34 | * Renders HTML attributes 35 | * 36 | * @param array $attributes 37 | */ 38 | protected function renderHtmlAttributes(array $attributes): string 39 | { 40 | return \implode('', \array_map([$this, 'htmlAttributesCallback'], \array_keys($attributes), \array_values($attributes))); 41 | } 42 | 43 | /** 44 | * Prepares an attribute key and value for HTML representation. 45 | * 46 | * It removes empty attributes. 47 | * 48 | * @param string $name The attribute name 49 | * @param string|bool|null $value The attribute value 50 | * 51 | * @return string the HTML representation of the HTML key attribute pair 52 | */ 53 | private function htmlAttributesCallback(string $name, $value): string 54 | { 55 | if (false === $value || null === $value) { 56 | return ''; 57 | } 58 | 59 | return ' '.$this->renderHtmlAttribute($name, $value); 60 | } 61 | 62 | /** 63 | * Escapes an HTML value 64 | */ 65 | protected function escape(string $value): string 66 | { 67 | return $this->fixDoubleEscape(\htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, $this->charset)); 68 | } 69 | 70 | /** 71 | * Fixes double escaped strings. 72 | * 73 | * @param string $escaped string to fix 74 | * 75 | * @return string A single escaped string 76 | */ 77 | protected function fixDoubleEscape(string $escaped): string 78 | { 79 | return (string) \preg_replace('/&([a-z]+|(#\d+)|(#x[\da-f]+));/i', '&$1;', $escaped); 80 | } 81 | 82 | /** 83 | * Get the HTML charset 84 | */ 85 | public function getCharset(): string 86 | { 87 | return $this->charset; 88 | } 89 | 90 | /** 91 | * Set the HTML charset 92 | */ 93 | public function setCharset(string $charset): void 94 | { 95 | $this->charset = $charset; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/RendererInterface.php: -------------------------------------------------------------------------------- 1 | $options some rendering options 25 | */ 26 | public function render(ItemInterface $item, array $options = []): string; 27 | } 28 | -------------------------------------------------------------------------------- /src/Knp/Menu/Renderer/RendererProviderInterface.php: -------------------------------------------------------------------------------- 1 | $defaultOptions 13 | */ 14 | public function __construct( 15 | private Environment $environment, 16 | string $template, 17 | private MatcherInterface $matcher, 18 | private array $defaultOptions = [] 19 | ) { 20 | $this->defaultOptions = \array_merge([ 21 | 'depth' => null, 22 | 'matchingDepth' => null, 23 | 'currentAsLink' => true, 24 | 'currentClass' => 'current', 25 | 'ancestorClass' => 'current_ancestor', 26 | 'firstClass' => 'first', 27 | 'lastClass' => 'last', 28 | 'template' => $template, 29 | 'compressed' => false, 30 | 'allow_safe_labels' => false, 31 | 'clear_matcher' => true, 32 | 'leaf_class' => null, 33 | 'branch_class' => null, 34 | ], $defaultOptions); 35 | } 36 | 37 | public function render(ItemInterface $item, array $options = []): string 38 | { 39 | $options = \array_merge($this->defaultOptions, $options); 40 | 41 | $html = $this->environment->render($options['template'], ['item' => $item, 'options' => $options, 'matcher' => $this->matcher]); 42 | 43 | if ($options['clear_matcher']) { 44 | $this->matcher->clear(); 45 | } 46 | 47 | return $html; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Knp/Menu/Resources/views/knp_menu.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'knp_menu_base.html.twig' %} 2 | 3 | {% macro attributes(attributes) %} 4 | {% for name, value in attributes %} 5 | {%- if value is not none and value is not same as(false) -%} 6 | {{- ' %s="%s"'|format(name, value is same as(true) ? name|e : value|e)|raw -}} 7 | {%- endif -%} 8 | {%- endfor -%} 9 | {% endmacro %} 10 | 11 | {% block compressed_root %} 12 | {% apply knp_menu_spaceless %} 13 | {{ block('root') }} 14 | {% endapply %} 15 | {% endblock %} 16 | 17 | {% block root %} 18 | {% set listAttributes = item.childrenAttributes %} 19 | {{ block('list') -}} 20 | {% endblock %} 21 | 22 | {% block list %} 23 | {% if item.hasChildren and options.depth is not same as(0) and item.displayChildren %} 24 | {% import _self as knp_menu %} 25 | 26 | {{ block('children') }} 27 | 28 | {% endif %} 29 | {% endblock %} 30 | 31 | {% block children %} 32 | {# save current variables #} 33 | {% set currentOptions = options %} 34 | {% set currentItem = item %} 35 | {# update the depth for children #} 36 | {% if options.depth is not none %} 37 | {% set options = options|merge({depth: currentOptions.depth - 1}) %} 38 | {% endif %} 39 | {# update the matchingDepth for children #} 40 | {% if options.matchingDepth is not none and options.matchingDepth > 0 %} 41 | {% set options = options|merge({matchingDepth: currentOptions.matchingDepth - 1}) %} 42 | {% endif %} 43 | {% for item in currentItem.children %} 44 | {{ block('item') }} 45 | {% endfor %} 46 | {# restore current variables #} 47 | {% set item = currentItem %} 48 | {% set options = currentOptions %} 49 | {% endblock %} 50 | 51 | {% block item %} 52 | {% if item.displayed %} 53 | {# building the class of the item #} 54 | {%- set classes = item.attribute('class') is not empty ? [item.attribute('class')] : [] %} 55 | {%- if matcher.isCurrent(item) %} 56 | {%- set classes = classes|merge([options.currentClass]) %} 57 | {%- elseif matcher.isAncestor(item, options.matchingDepth) %} 58 | {%- set classes = classes|merge([options.ancestorClass]) %} 59 | {%- endif %} 60 | {%- if item.actsLikeFirst %} 61 | {%- set classes = classes|merge([options.firstClass]) %} 62 | {%- endif %} 63 | {%- if item.actsLikeLast %} 64 | {%- set classes = classes|merge([options.lastClass]) %} 65 | {%- endif %} 66 | 67 | {# Mark item as "leaf" (no children) or as "branch" (has children that are displayed) #} 68 | {% if item.hasChildren and options.depth is not same as(0) %} 69 | {% if options.branch_class is not empty and item.displayChildren %} 70 | {%- set classes = classes|merge([options.branch_class]) %} 71 | {% endif %} 72 | {% elseif options.leaf_class is not empty %} 73 | {%- set classes = classes|merge([options.leaf_class]) %} 74 | {%- endif %} 75 | 76 | {%- set attributes = item.attributes %} 77 | {%- if classes is not empty %} 78 | {%- set attributes = attributes|merge({class: classes|join(' ')}) %} 79 | {%- endif %} 80 | {# displaying the item #} 81 | {% import _self as knp_menu %} 82 | 83 | {%- if item.uri is not empty and (not matcher.isCurrent(item) or options.currentAsLink) %} 84 | {{ block('linkElement') }} 85 | {%- else %} 86 | {{ block('spanElement') }} 87 | {%- endif %} 88 | {# render the list of children #} 89 | {%- set childrenClasses = item.childrenAttribute('class') is not empty ? [item.childrenAttribute('class')] : [] %} 90 | {%- set childrenClasses = childrenClasses|merge(['menu_level_' ~ item.level]) %} 91 | {%- set listAttributes = item.childrenAttributes|merge({class: childrenClasses|join(' ')}) %} 92 | {{ block('list') }} 93 | 94 | {% endif %} 95 | {% endblock %} 96 | 97 | {% block linkElement %}{% import _self as knp_menu %}{{ block('label') }}{% endblock %} 98 | 99 | {% block spanElement %}{% import _self as knp_menu %}{{ block('label') }}{% endblock %} 100 | 101 | {% block label %}{% if options.allow_safe_labels and item.getExtra('safe_label', false) %}{{ item.label|raw }}{% else %}{{ item.label }}{% endif %}{% endblock %} 102 | -------------------------------------------------------------------------------- /src/Knp/Menu/Resources/views/knp_menu_base.html.twig: -------------------------------------------------------------------------------- 1 | {% if options.compressed %}{{ block('compressed_root') }}{% else %}{{ block('root') }}{% endif %} 2 | -------------------------------------------------------------------------------- /src/Knp/Menu/Resources/views/knp_menu_ordered.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'knp_menu.html.twig' %} 2 | 3 | {% block list %} 4 | {% import 'knp_menu.html.twig' as macros %} 5 | 6 | {% if item.hasChildren and options.depth is not same as(0) and item.displayChildren %} 7 | 8 | {{ block('children') }} 9 | 10 | {% endif %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/Knp/Menu/Twig/Helper.php: -------------------------------------------------------------------------------- 1 | $path 29 | * @param array $options 30 | * 31 | * @throws \BadMethodCallException when there is no menu provider and the menu is given by name 32 | * @throws \LogicException 33 | * @throws \InvalidArgumentException when the path is invalid 34 | */ 35 | public function get($menu, array $path = [], array $options = []): ItemInterface 36 | { 37 | if (!$menu instanceof ItemInterface) { 38 | if (null === $this->menuProvider) { 39 | throw new \BadMethodCallException('A menu provider must be set to retrieve a menu'); 40 | } 41 | 42 | $menuName = $menu; 43 | $menu = $this->menuProvider->get($menuName, $options); 44 | 45 | if (!$menu instanceof ItemInterface) { 46 | throw new \LogicException(\sprintf('The menu "%s" exists, but is not a valid menu item object. Check where you created the menu to be sure it returns an ItemInterface object.', $menuName)); 47 | } 48 | } 49 | 50 | foreach ($path as $child) { 51 | $menu = $menu->getChild($child); 52 | if (null === $menu) { 53 | throw new \InvalidArgumentException(\sprintf('The menu has no child named "%s"', $child)); 54 | } 55 | } 56 | 57 | return $menu; 58 | } 59 | 60 | /** 61 | * Renders a menu with the specified renderer. 62 | * 63 | * If the argument is an array, it will follow the path in the tree to 64 | * get the needed item. The first element of the array is the whole menu. 65 | * If the menu is a string instead of an ItemInterface, the provider 66 | * will be used. 67 | * 68 | * @param ItemInterface|string|array $menu 69 | * @param array $options 70 | * 71 | * @throws \InvalidArgumentException 72 | */ 73 | public function render($menu, array $options = [], ?string $renderer = null): string 74 | { 75 | $menu = $this->castMenu($menu); 76 | 77 | return $this->rendererProvider->get($renderer)->render($menu, $options); 78 | } 79 | 80 | /** 81 | * Renders an array ready to be used for breadcrumbs. 82 | * 83 | * Each element in the array will be an array with 3 keys: 84 | * - `label` containing the label of the item 85 | * - `url` containing the url of the item (may be `null`) 86 | * - `item` containing the original item (may be `null` for the extra items) 87 | * 88 | * The subItem can be one of the following forms 89 | * * 'subItem' 90 | * * ItemInterface object 91 | * * ['subItem' => '@homepage'] 92 | * * ['subItem1', 'subItem2'] 93 | * * [['label' => 'subItem1', 'url' => '@homepage'], ['label' => 'subItem2']] 94 | * 95 | * @param mixed $menu 96 | * @param mixed $subItem A string or array to append onto the end of the array 97 | * 98 | * @phpstan-param string|ItemInterface|array|\Traversable $subItem 99 | * 100 | * @return array> 101 | * 102 | * @phpstan-return list 103 | */ 104 | public function getBreadcrumbsArray($menu, $subItem = null): array 105 | { 106 | if (null === $this->menuManipulator) { 107 | throw new \BadMethodCallException('The menu manipulator must be set to get the breadcrumbs array'); 108 | } 109 | 110 | $menu = $this->castMenu($menu); 111 | 112 | return $this->menuManipulator->getBreadcrumbsArray($menu, $subItem); 113 | } 114 | 115 | /** 116 | * Returns the current item of a menu. 117 | * 118 | * @param ItemInterface|string|array $menu 119 | */ 120 | public function getCurrentItem($menu): ?ItemInterface 121 | { 122 | $menu = $this->castMenu($menu); 123 | 124 | return $this->retrieveCurrentItem($menu); 125 | } 126 | 127 | /** 128 | * @param ItemInterface|string|array $menu 129 | */ 130 | private function castMenu($menu): ItemInterface 131 | { 132 | if (!$menu instanceof ItemInterface) { 133 | $path = []; 134 | if (\is_array($menu)) { 135 | if (empty($menu)) { 136 | throw new \InvalidArgumentException('The array cannot be empty'); 137 | } 138 | $path = $menu; 139 | $menu = \array_shift($path); 140 | } 141 | 142 | return $this->get($menu, $path); 143 | } 144 | 145 | return $menu; 146 | } 147 | 148 | private function retrieveCurrentItem(ItemInterface $item): ?ItemInterface 149 | { 150 | if (null === $this->matcher) { 151 | throw new \BadMethodCallException('The matcher must be set to get the current item of a menu'); 152 | } 153 | 154 | if ($this->matcher->isCurrent($item)) { 155 | return $item; 156 | } 157 | 158 | if ($this->matcher->isAncestor($item)) { 159 | foreach ($item->getChildren() as $child) { 160 | $currentItem = $this->retrieveCurrentItem($child); 161 | 162 | if (null !== $currentItem) { 163 | return $currentItem; 164 | } 165 | } 166 | } 167 | 168 | return null; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Knp/Menu/Twig/MenuExtension.php: -------------------------------------------------------------------------------- 1 | runtimeExtension = new MenuRuntimeExtension($helper, $matcher, $menuManipulator); 25 | } 26 | } 27 | 28 | public function getFunctions(): array 29 | { 30 | $legacy = null !== $this->runtimeExtension; 31 | 32 | return [ 33 | new TwigFunction('knp_menu_get', $legacy ? [$this, 'get'] : [MenuRuntimeExtension::class, 'get']), 34 | new TwigFunction('knp_menu_render', $legacy ? [$this, 'render'] : [MenuRuntimeExtension::class, 'render'], ['is_safe' => ['html']]), 35 | new TwigFunction('knp_menu_get_breadcrumbs_array', $legacy ? [$this, 'getBreadcrumbsArray'] : [MenuRuntimeExtension::class, 'getBreadcrumbsArray']), 36 | new TwigFunction('knp_menu_get_current_item', $legacy ? [$this, 'getCurrentItem'] : [MenuRuntimeExtension::class, 'getCurrentItem']), 37 | ]; 38 | } 39 | 40 | public function getFilters(): array 41 | { 42 | $legacy = null !== $this->runtimeExtension; 43 | 44 | return [ 45 | new TwigFilter('knp_menu_as_string', $legacy ? [$this, 'pathAsString'] : [MenuRuntimeExtension::class, 'pathAsString']), 46 | new TwigFilter('knp_menu_spaceless', [self::class, 'spaceless'], ['is_safe' => ['html']]), 47 | ]; 48 | } 49 | 50 | public function getTests(): array 51 | { 52 | $legacy = null !== $this->runtimeExtension; 53 | 54 | return [ 55 | new TwigTest('knp_menu_current', $legacy ? [$this, 'isCurrent'] : [MenuRuntimeExtension::class, 'isCurrent']), 56 | new TwigTest('knp_menu_ancestor', $legacy ? [$this, 'isAncestor'] : [MenuRuntimeExtension::class, 'isAncestor']), 57 | ]; 58 | } 59 | 60 | /** 61 | * @param array $path 62 | * @param array $options 63 | */ 64 | public function get(ItemInterface|string $menu, array $path = [], array $options = []): ItemInterface 65 | { 66 | assert(null !== $this->runtimeExtension); 67 | 68 | return $this->runtimeExtension->get($menu, $path, $options); 69 | } 70 | 71 | /** 72 | * @param string|ItemInterface|array $menu 73 | * @param array $options 74 | */ 75 | public function render(array|ItemInterface|string $menu, array $options = [], ?string $renderer = null): string 76 | { 77 | assert(null !== $this->runtimeExtension); 78 | 79 | return $this->runtimeExtension->render($menu, $options, $renderer); 80 | } 81 | 82 | /** 83 | * @param string|ItemInterface|array $menu 84 | * @param string|array|null $subItem 85 | * 86 | * @phpstan-param string|ItemInterface|array|\Traversable $subItem 87 | * 88 | * @return array> 89 | * @phpstan-return list 90 | */ 91 | public function getBreadcrumbsArray(array|ItemInterface|string $menu, array|string|null $subItem = null): array 92 | { 93 | assert(null !== $this->runtimeExtension); 94 | 95 | return $this->runtimeExtension->getBreadcrumbsArray($menu, $subItem); 96 | } 97 | 98 | public function getCurrentItem(ItemInterface|string $menu): ItemInterface 99 | { 100 | assert(null !== $this->runtimeExtension); 101 | 102 | return $this->runtimeExtension->getCurrentItem($menu); 103 | } 104 | 105 | public function pathAsString(ItemInterface $menu, string $separator = ' > '): string 106 | { 107 | assert(null !== $this->runtimeExtension); 108 | 109 | return $this->runtimeExtension->pathAsString($menu, $separator); 110 | } 111 | 112 | public function isCurrent(ItemInterface $item): bool 113 | { 114 | assert(null !== $this->runtimeExtension); 115 | 116 | return $this->runtimeExtension->isCurrent($item); 117 | } 118 | 119 | public function isAncestor(ItemInterface $item, ?int $depth = null): bool 120 | { 121 | assert(null !== $this->runtimeExtension); 122 | 123 | return $this->runtimeExtension->isAncestor($item, $depth); 124 | } 125 | 126 | /** 127 | * @internal 128 | */ 129 | public static function spaceless(string $content): string 130 | { 131 | return trim((string) preg_replace('/>\s+<', $content)); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Knp/Menu/Twig/MenuRuntimeExtension.php: -------------------------------------------------------------------------------- 1 | $path 23 | * @param array $options 24 | */ 25 | public function get(ItemInterface|string $menu, array $path = [], array $options = []): ItemInterface 26 | { 27 | return $this->helper->get($menu, $path, $options); 28 | } 29 | 30 | /** 31 | * Renders a menu with the specified renderer. 32 | * 33 | * @param string|ItemInterface|array $menu 34 | * @param array $options 35 | */ 36 | public function render(array|ItemInterface|string $menu, array $options = [], ?string $renderer = null): string 37 | { 38 | return $this->helper->render($menu, $options, $renderer); 39 | } 40 | 41 | /** 42 | * Returns an array ready to be used for breadcrumbs. 43 | * 44 | * @param string|ItemInterface|array $menu 45 | * @param string|array|null $subItem 46 | * 47 | * @phpstan-param string|ItemInterface|array|\Traversable $subItem 48 | * 49 | * @return array> 50 | * @phpstan-return list 51 | */ 52 | public function getBreadcrumbsArray(array|ItemInterface|string $menu, array|string|null $subItem = null): array 53 | { 54 | return $this->helper->getBreadcrumbsArray($menu, $subItem); 55 | } 56 | 57 | /** 58 | * Returns the current item of a menu. 59 | */ 60 | public function getCurrentItem(ItemInterface|string $menu): ItemInterface 61 | { 62 | $rootItem = $this->get($menu); 63 | 64 | $currentItem = $this->helper->getCurrentItem($rootItem); 65 | 66 | if (null === $currentItem) { 67 | $currentItem = $rootItem; 68 | } 69 | 70 | return $currentItem; 71 | } 72 | 73 | /** 74 | * A string representation of this menu item 75 | * 76 | * e.g. Top Level > Second Level > This menu 77 | */ 78 | public function pathAsString(ItemInterface $menu, string $separator = ' > '): string 79 | { 80 | if (null === $this->menuManipulator) { 81 | throw new \BadMethodCallException('The menu manipulator must be set to get the breadcrumbs array'); 82 | } 83 | 84 | return $this->menuManipulator->getPathAsString($menu, $separator); 85 | } 86 | 87 | /** 88 | * Checks whether an item is current. 89 | */ 90 | public function isCurrent(ItemInterface $item): bool 91 | { 92 | if (null === $this->matcher) { 93 | throw new \BadMethodCallException('The matcher must be set to get the breadcrumbs array'); 94 | } 95 | 96 | return $this->matcher->isCurrent($item); 97 | } 98 | 99 | /** 100 | * Checks whether an item is the ancestor of a current item. 101 | * 102 | * @param int|null $depth The max depth to look for the item 103 | */ 104 | public function isAncestor(ItemInterface $item, ?int $depth = null): bool 105 | { 106 | if (null === $this->matcher) { 107 | throw new \BadMethodCallException('The matcher must be set to get the breadcrumbs array'); 108 | } 109 | 110 | return $this->matcher->isAncestor($item, $depth); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Knp/Menu/Util/MenuManipulator.php: -------------------------------------------------------------------------------- 1 | getParent()) { 17 | $this->moveChildToPosition($parent, $item, $position); 18 | } 19 | } 20 | 21 | /** 22 | * Moves child to specified position. Rearrange other children accordingly. 23 | * 24 | * @param ItemInterface $child Child to move 25 | * @param int $position Position to move child to 26 | */ 27 | public function moveChildToPosition(ItemInterface $item, ItemInterface $child, int $position): void 28 | { 29 | $name = $child->getName(); 30 | $order = \array_keys($item->getChildren()); 31 | 32 | $oldPosition = \array_search($name, $order); 33 | unset($order[$oldPosition]); 34 | 35 | $order = \array_values($order); 36 | 37 | \array_splice($order, $position, 0, $name); 38 | $item->reorderChildren($order); 39 | } 40 | 41 | /** 42 | * Moves item to first position. Rearrange siblings accordingly. 43 | */ 44 | public function moveToFirstPosition(ItemInterface $item): void 45 | { 46 | $this->moveToPosition($item, 0); 47 | } 48 | 49 | /** 50 | * Moves item to last position. Rearrange siblings accordingly. 51 | */ 52 | public function moveToLastPosition(ItemInterface $item): void 53 | { 54 | if (null !== $parent = $item->getParent()) { 55 | $this->moveToPosition($item, $parent->count()); 56 | } 57 | } 58 | 59 | /** 60 | * Get slice of menu as another menu. 61 | * 62 | * If offset and/or length are numeric, it works like in array_slice function: 63 | * 64 | * If offset is non-negative, slice will start at the offset. 65 | * If offset is negative, slice will start that far from the end. 66 | * 67 | * If length is null, slice will have all elements. 68 | * If length is positive, slice will have that many elements. 69 | * If length is negative, slice will stop that far from the end. 70 | * 71 | * It's possible to mix names/object/numeric, for example: 72 | * slice("child1", 2); 73 | * slice(3, $child5); 74 | * Note: when using a child as limit, it will not be included in the returned menu. 75 | * the slice is done before this menu. 76 | * 77 | * @param mixed $offset name of child, child object, or numeric offset 78 | * @param string|int|ItemInterface $length name of child, child object, or numeric length 79 | */ 80 | public function slice(ItemInterface $item, $offset, $length = null): ItemInterface 81 | { 82 | $names = \array_keys($item->getChildren()); 83 | if ($offset instanceof ItemInterface) { 84 | $offset = $offset->getName(); 85 | } 86 | if (!\is_int($offset)) { 87 | $offset = \array_search($offset, $names, true); 88 | if (false === $offset) { 89 | throw new \InvalidArgumentException('Not found.'); 90 | } 91 | } 92 | 93 | if (null !== $length) { 94 | if ($length instanceof ItemInterface) { 95 | $length = $length->getName(); 96 | } 97 | if (!\is_int($length)) { 98 | $index = \array_search($length, $names, true); 99 | $length = ($index < $offset) ? 0 : $index - $offset; 100 | } 101 | } 102 | 103 | $slicedItem = $item->copy(); 104 | $children = \array_slice($slicedItem->getChildren(), $offset, $length); 105 | $slicedItem->setChildren($children); 106 | 107 | return $slicedItem; 108 | } 109 | 110 | /** 111 | * Split menu into two distinct menus. 112 | * 113 | * @param string|int|ItemInterface $length name of child, child object, or numeric length 114 | * 115 | * @phpstan-return array{primary: ItemInterface, secondary: ItemInterface} 116 | * 117 | * @return array Array with two menus, with "primary" and "secondary" key 118 | */ 119 | public function split(ItemInterface $item, $length): array 120 | { 121 | return [ 122 | 'primary' => $this->slice($item, 0, $length), 123 | 'secondary' => $this->slice($item, $length), 124 | ]; 125 | } 126 | 127 | /** 128 | * Calls a method recursively on all of the children of this item 129 | * 130 | * @example 131 | * $menu->callRecursively('setShowChildren', [false]); 132 | * 133 | * @param array $arguments 134 | */ 135 | public function callRecursively(ItemInterface $item, string $method, array $arguments = []): void 136 | { 137 | $item->$method(...$arguments); 138 | 139 | foreach ($item->getChildren() as $child) { 140 | $this->callRecursively($child, $method, $arguments); 141 | } 142 | } 143 | 144 | /** 145 | * A string representation of this menu item 146 | * 147 | * e.g. Top Level > Second Level > This menu 148 | */ 149 | public function getPathAsString(ItemInterface $item, string $separator = ' > '): string 150 | { 151 | $children = []; 152 | $obj = $item; 153 | 154 | do { 155 | $children[] = $obj->getLabel(); 156 | } while ($obj = $obj->getParent()); 157 | 158 | return \implode($separator, \array_reverse($children)); 159 | } 160 | 161 | /** 162 | * @param int|null $depth the depth until which children should be exported (null means unlimited) 163 | * 164 | * @return array 165 | */ 166 | public function toArray(ItemInterface $item, ?int $depth = null): array 167 | { 168 | $array = [ 169 | 'name' => $item->getName(), 170 | 'label' => $item->getLabel(), 171 | 'uri' => $item->getUri(), 172 | 'attributes' => $item->getAttributes(), 173 | 'labelAttributes' => $item->getLabelAttributes(), 174 | 'linkAttributes' => $item->getLinkAttributes(), 175 | 'childrenAttributes' => $item->getChildrenAttributes(), 176 | 'extras' => $item->getExtras(), 177 | 'display' => $item->isDisplayed(), 178 | 'displayChildren' => $item->getDisplayChildren(), 179 | 'current' => $item->isCurrent(), 180 | ]; 181 | 182 | // export the children as well, unless explicitly disabled 183 | if (0 !== $depth) { 184 | $childDepth = null === $depth ? null : $depth - 1; 185 | $array['children'] = []; 186 | foreach ($item->getChildren() as $key => $child) { 187 | $array['children'][$key] = $this->toArray($child, $childDepth); 188 | } 189 | } 190 | 191 | return $array; 192 | } 193 | 194 | /** 195 | * Renders an array ready to be used for breadcrumbs. 196 | * 197 | * Each element in the array will be an array with 3 keys: 198 | * - `label` containing the label of the item 199 | * - `url` containing the url of the item (may be `null`) 200 | * - `item` containing the original item (may be `null` for the extra items) 201 | * 202 | * The subItem can be one of the following forms 203 | * * 'subItem' 204 | * * ItemInterface object 205 | * * ['subItem' => '@homepage'] 206 | * * ['subItem1', 'subItem2'] 207 | * * [['label' => 'subItem1', 'url' => '@homepage'], ['label' => 'subItem2']] 208 | * 209 | * @param string|ItemInterface|array|\Traversable $subItem A string or array to append onto the end of the array 210 | * 211 | * @phpstan-param string|ItemInterface|array|\Traversable $subItem 212 | * 213 | * @return array> 214 | * @phpstan-return list 215 | * 216 | * @throws \InvalidArgumentException if an element of the subItem is invalid 217 | */ 218 | public function getBreadcrumbsArray(ItemInterface $item, $subItem = null): array 219 | { 220 | $breadcrumbs = $this->buildBreadcrumbsArray($item); 221 | 222 | if (null === $subItem) { 223 | return $breadcrumbs; 224 | } 225 | 226 | if ($subItem instanceof ItemInterface) { 227 | $breadcrumbs[] = $this->getBreadcrumbsItem($subItem); 228 | 229 | return $breadcrumbs; 230 | } 231 | 232 | if (!\is_array($subItem) && !$subItem instanceof \Traversable) { 233 | $subItem = [$subItem]; 234 | } 235 | 236 | foreach ($subItem as $key => $value) { 237 | switch (true) { 238 | case $value instanceof ItemInterface: 239 | $value = $this->getBreadcrumbsItem($value); 240 | break; 241 | 242 | case \is_array($value): 243 | // Assume we already have the appropriate array format for the element 244 | break; 245 | 246 | case \is_int($key) && \is_string($value): 247 | $value = [ 248 | 'label' => $value, 249 | 'uri' => null, 250 | 'item' => null, 251 | ]; 252 | break; 253 | 254 | case \is_scalar($value): 255 | $value = [ 256 | 'label' => (string) $key, 257 | 'uri' => (string) $value, 258 | 'item' => null, 259 | ]; 260 | break; 261 | 262 | case null === $value: 263 | $value = [ 264 | 'label' => (string) $key, 265 | 'uri' => null, 266 | 'item' => null, 267 | ]; 268 | break; 269 | 270 | default: 271 | throw new \InvalidArgumentException(\sprintf('Invalid value supplied for the key "%s". It should be an item, an array or a scalar', $key)); 272 | } 273 | 274 | $breadcrumbs[] = $value; 275 | } 276 | 277 | return $breadcrumbs; 278 | } 279 | 280 | /** 281 | * @phpstan-return list 282 | */ 283 | private function buildBreadcrumbsArray(ItemInterface $item): array 284 | { 285 | $breadcrumb = []; 286 | 287 | do { 288 | $breadcrumb[] = $this->getBreadcrumbsItem($item); 289 | } while ($item = $item->getParent()); 290 | 291 | return \array_reverse($breadcrumb); 292 | } 293 | 294 | /** 295 | * @phpstan-return array{label: string, uri: string|null, item: ItemInterface} 296 | */ 297 | private function getBreadcrumbsItem(ItemInterface $item): array 298 | { 299 | return [ 300 | 'label' => $item->getLabel(), 301 | 'uri' => $item->getUri(), 302 | 'item' => $item, 303 | ]; 304 | } 305 | } 306 | --------------------------------------------------------------------------------