Menus and Navigation
Navigation is data the operator edits in the panel. The theme receives finished trees and prints them. Every other address comes from a route key, never from a written path.
Overview
A theme never decides what is in the menu. The operator builds the trees in the panel. The platform resolves each node's address and hands the view ready arrays. The theme owns the markup: how a dropdown looks, where the mobile drawer lives, which node deserves an icon.
The same rule covers links a theme writes itself. A "Log in" or "Cart" link is addressed by its route key, not by a path. Route paths are translated per language and can be renamed. A written path breaks on the second language, and again on the first rename.
Structure
The Trees a Client Page Receives
The Shape of a Node
$node = [
'id' => 12,
'parent' => 0,
'icon' => 'bi bi-hdd-rack', // a class string, not an address
'target' => 1, // 1 opens in a new tab
'page' => 'category/551', // the panel's page identifier, already resolved below
'title' => 'Hosting', // in the ACTIVE language
'link' => 'https://example.com/hosting', // may be EMPTY: a heading-only node
'extra' => [
'desc' => 'Shared and reseller plans', // second line inside a dropdown row
// Badge. The two colours come from the panel's colour pickers, so they arrive as
// hex strings with a leading hash and go straight into a style attribute.
'tag' => ['name' => 'New', 'color' => '#RRGGBB', 'text_color' => '#RRGGBB', 'icon' => 'bi bi-stars'],
'mega' => '<div class="mega">…</div>', // operator-authored markup, links already resolved
],
'children' => [ /* same shape, recursively */ ],
];
The address is resolved before the view sees it. A node bound to a panel page carries the finished link in link; no template converts an identifier itself.
Step by Step
Print the Header Tree
- Loop the top level and branch on what the node carries. Children mean a dropdown, a mega panel means raw markup, neither means a plain item.
- Guard the address. A node may be a heading with no link at all. Print the attribute only when
linkis not empty. - Print the mega panel unescaped. It is operator-authored HTML and the engine escapes by default, so otherwise the tags show up on the page.
- Close the list with the navigation injection point, so a module can add an item without editing the partial. The header then survives any menu the operator builds.
Print the Footer Tree
- Loop the top level as columns and print each node's title as the column heading.
- Loop that node's children as the column's links, with the same empty-address guard.
- Follow the columns with the footer injection point. The footer then grows a column the operator adds, with no template change.
Address the Pages the Theme Writes Itself
- Use the link function with a route key for platform pages: the cart, sign-in, the account overview, a ticket form.
- Pass positional parameters when the route takes them, in the order the pattern declares them.
- Use the page form for a record the operator picked in the panel: a content page, a category. It takes the same identifier the menu system stores.
- Never concatenate a query string by hand. Open the page and confirm the address survives a language switch, which is the point of a key.
Hide Links to Pages the Theme Does Not Serve
- List the route keys your theme does not implement in the manifest's meta block. The platform then answers those addresses with a not-found instead of half a page.
- Expose the same list to templates as a keyed map from the theme's hooks file. The template sandbox has no array search function, so a map is what a condition can read.
- Wrap every link that leads there in that condition. A closed route with a visible link is a dead end the visitor finds before you do.
Reference
The Menus Helper
// $type: 'header' | 'footer' | 'mobile' | 'clientArea' | 'pages-sidebar'
// $parent: 0 for the whole tree, or a node id to start below it
// $lang: empty means the active language
public static function tree(string $type = 'header', int $parent = 0, string $lang = ''): array;
// Resolve {link page='...'} / {link route='...'} placeholders inside operator-authored
// markup. Already applied to a node's mega panel; call it for your own stored HTML.
public static function resolveLinks(string $html): string;
type and lang. A theme adds or drops a node the panel does not manage here. Edit the rows in place; the return is ignored.
The Link Function
LinkGenerator::client(), LinkGenerator::convert_to_link() and, for a query string, LinkGenerator::wQS().
// $lang: empty means the active language; pass one to build the same page in another.
public static function client($route = '', $params = [], $lang = ''): string;
// Panel identifier ("pages/4", "category/551", "home") to a finished address.
public static function convert_to_link(string $arg): string;
// Append a query string. Never concatenate one by hand: this one knows whether the
// address already carries a "?".
public static function wQS(string|bool|null $url, array|string $params = []): string;
Example
The header partial, then the hooks file that closes a section this theme does not serve. Both halves are shown: the template's condition is meaningless without the map that feeds it.
<ul class="nav site-nav">
{foreach $header_menu as $item}
{if $item.children}
<li class="nav-item dropdown">
<a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a>
<div class="dropdown-menu">
{foreach $item.children as $child}
<a class="dropdown-item"{if $child.link} href="{$child.link}"{/if}{if $child.target} target="_blank" rel="noopener"{/if}>
<i class="{$child.icon}"></i>
<span>{$child.title}</span>
{if $child.extra.desc ?? ''}<span class="item-desc">{$child.extra.desc}</span>{/if}
</a>
{/foreach}
</div>
</li>
{elseif $item.extra.mega ?? ''}
{* Operator-authored markup: printed raw, or the tags themselves show up on the page. *}
<li class="nav-item dropdown">
<a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a>
<div class="dropdown-menu">{$item.extra.mega nofilter}</div>
</li>
{else}
<li class="nav-item"><a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a></li>
{/if}
{/foreach}
{* A section this theme closed: the map comes from hooks.php, the route from its key. *}
{if !$route_off.affiliate}
<li class="nav-item"><a class="nav-link" href="{link route='affiliate'}">{lang key='nav_affiliate'}</a></li>
{/if}
{hook name='ui:client.nav.items'}
</ul>
<a class="btn" href="{link route='cart'}">{lang key='nav_cart'}</a>
<a class="btn" href="{link route='invoice-detail' p1=$latest_invoice_id}">{lang key='nav_last_invoice'}</a>
Hook::add("filter:template.variables", 1, function ($template, $data) {
// Keyed map, not a list: the template sandbox has no in_array(), so a condition
// can only ask {if !$route_off.affiliate}.
$data["route_off"] = array_fill_keys(Theme::active()->meta()["disabled_routes"] ?? [], true);
return $data;
});
/*
* A node the panel does not manage. By reference: the tree is changed in place and
* the context says which tree this is, so the footer is not touched by a header rule.
*/
Hook::add("filter:client.menu", 10, function (&$rows, $ctx) {
if (($ctx["type"] ?? '') !== 'header') return;
$rows[] = [
'id' => 0,
'parent' => 0,
'icon' => 'bi bi-life-preserver',
'target' => 0,
'page' => '',
'title' => Theme::active()->lang('nav_status'),
'link' => LinkGenerator::client('contact'),
'extra' => [],
'children' => [],
];
});
Pitfalls
A parent that only opens a dropdown, or a footer column heading, carries no address. Printing the attribute unconditionally emits an empty one. The browser resolves that to the current page, and a screen reader announces a link to nowhere.
Route paths are translated, and the operator can rename them. Address platform pages by route key and content by page identifier. The only paths a theme may write are its own file-based pages, whose slug is the address by definition.
The trees arrive built, resolved, filtered to active rows and cached per language. A query in a template repeats work already paid for and skips the hook every other listener relies on.
With no mobile menu built, the drawer receives the header tree. A drawer that assumes a flat list prints a dropdown's children as top-level rows, so handle children there as well.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.