Pages
Contents
Navigation β Component Concept
Status: CONCEPT Β· Created March 2026 Parent:
GLOBAL_concept-ecosystem.md,GLOBAL_concept-web.mdImplemented in:@xpulse/controllerInspiration: Jβ’Frame Theming Component βNavigationManager+@NavigationItemAnnotation
Problem
After controller discovery the router knows all registered routes β
including parameterised routes (/info/:page?), API endpoints (POST /contact),
and internal sub-routes that should never appear as navigation links.
Blindly outputting all routes as navigation creates a filtering problem. Maintaining navigation manually creates a synchronisation problem.
Solution: Opt-in Navigation via `define()` + Event Pipeline
Navigation entries are declared inside the controller β explicitly, not automatically.
Only what is marked with this.navItem() appears in the navigation.
After discovery, navigation passes through an event pipeline that
app code can use to filter, extend, or reorder β
before the finished navigation is injected as _nav into templates.
Concept Overview
| Controller Discovery |
| β |
| navItem() declarations collected β opt-in, directly in the controller |
| β |
| controller:navigation:collected β Event: app code can filter/extend |
| β |
| controller:navigation:ready β Event: finished navigation, read-only |
| β |
| template:render:before β _nav injected into template data |
| β |
| Template renders <nav> |
Opt-in in the Controller
Navigation is declared in define() β the same hook that also contains
route overrides (overrideRoute, overrideMethod).
| // src/controllers/info.js |
| export default class InfoController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Info', position: 10 }); |
| this.navItem('team', { label: 'Team', position: 20 }); |
| // guide() stays silent β reachable, but no nav entry |
| } |
| index() { ... } // β GET /info |
| team() { ... } // β GET /info/team |
| guide() { ... } // β GET /info/guide (no navItem β no nav link) |
| } |
| // src/controllers/index.js |
| export default class IndexController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Home', position: 0 }); |
| } |
| index() { ... } // β GET / |
| } |
| // src/controllers/contact.js |
| export default class ContactController extends Controller { |
| define() { |
| this.navItem('index', { label: 'Contact', position: 30 }); |
| // submit() is POST β no navItem, makes no sense |
| } |
| index() { ... } // β GET /contact |
| submit() { ... } // β POST /contact |
| } |
`navItem()` API
| this.navItem(method, options) |
| Option | Type | Default | Description |
|---|---|---|---|
key |
string |
auto-generated | Custom key β overrides the auto-generated one |
label |
string |
method name (ucfirst) | Display name in the nav link |
position |
number |
0 |
Sort order (ascending) |
parent |
string |
'' |
Key of another navItem β sub-navigation |
icon |
string |
'' |
Optional icon identifier (e.g. CSS class) |
target |
string |
'_self' |
Link target (_self, _blank) |
method refers to a method on the controller itself β the router
resolves the correct href automatically. No manual path typing.
Key Construction
Auto-generated Key
The key is assembled from the controller slug and the method name,
separated by _:
| {controllerSlug}_{methodName} |
The controller slug is derived from the file path relative to src/controllers/ β
directory separators become _, the file extension is dropped:
| src/controllers/index.js β controllerSlug: index |
| src/controllers/info.js β controllerSlug: info |
| src/controllers/tool/chat.js β controllerSlug: tool_chat |
| src/controllers/admin/user.js β controllerSlug: admin_user |
This results in the following keys:
| index.js β index() β key: index_index |
| info.js β index() β key: info_index |
| info.js β team() β key: info_team |
| info.js β guide() β key: info_guide |
| tool/chat.js β index() β key: tool_chat_index |
| tool/chat.js β guide() β key: tool_chat_guide |
| admin/user.js β index() β key: admin_user_index |
| admin/user.js β edit() β key: admin_user_edit |
Finding the Key at Development Time
As a developer you need the key for example when setting parent in another
controller. There are three ways:
1. Derive from the file path (no tooling needed):
| src/controllers/{path}/{file}.js β {path}_{file}_{method} |
| Underscores instead of slashes, no .js |
2. Logging on startup β @xpulse/controller logs all navItems at debug level:
| [controller] navItem registered: info_team β GET /info/team "Team" |
| [controller] navItem registered: info_index β GET /info "Info" |
3. @xpulse/debug Collector (if installed) β shows all registered
navItems in the Debug Toolbar / Web Profiler under "Navigation".
Setting a Custom Key
If the auto-generated key is too long or unwieldy, a custom one can be set:
| // src/controllers/tool/chat.js |
| define() { |
| this.navItem('index', { key: 'chat', label: 'Chat', position: 10 }); |
| } |
The custom key 'chat' completely replaces 'tool_chat_index' β
in the data structure, in the event payload, and as a parent reference.
| // Another controller references the custom key as parent: |
| this.navItem('index', { label: 'Chat Guide', parent: 'chat' }); |
Caution: Custom keys are the developer's responsibility. If a custom key collides with another auto-generated or custom key, the error handling kicks in (see below).
NavItem Data Structure
What the event pipeline receives and passes on:
| { |
| key: 'info_team', // unique key: controllerKey_methodName |
| label: 'Team', |
| href: '/info/team', // resolved by the router |
| position: 20, |
| parent: 'info_index', // '' if no parent |
| icon: '', |
| target: '_self', |
| active: false, // true when current route matches |
| children: [], // navItems with matching parent key |
| } |
Event Pipeline
`controller:navigation:collected`
Fired after all controller discovery is complete and all navItem() declarations
have been collected β before the navigation is finalised.
| // Payload |
| { items: NavItem[] } |
The handler can mutate the items array β filter, add to, or reorder it.
The return value is ignored; mutation is the way.
| // Example: filtering |
| event.on('controller:navigation:collected', ({ items }) => { |
| // Show admin link only when logged in |
| const filtered = items.filter(item => { |
| if (item.key === 'admin_index') return isAuthenticated(); |
| return true; |
| }); |
| items.splice(0, items.length, ...filtered); |
| }); |
| // Example: adding an external link |
| event.on('controller:navigation:collected', ({ items }) => { |
| items.push({ |
| key: 'extern_github', |
| label: 'GitHub', |
| href: 'https://github.com/xpulse1', |
| position: 99, |
| parent: '', |
| icon: '', |
| target: '_blank', |
| active: false, |
| children: [], |
| }); |
| }); |
| // Example: reordering |
| event.on('controller:navigation:collected', ({ items }) => { |
| items.sort((a, b) => a.position - b.position); |
| }); |
`controller:navigation:ready`
Fired after the pipeline is complete β navigation is finalised, parent-child structure built, sorted.
Read-only. Mutation here has no effect anymore.
| // Payload |
| { items: NavItem[] } // already hierarchical β items with children[] |
Useful for logging, debugging, or external systems that need to know the
finished navigation (e.g. sitemap generator, @xpulse/debug collector).
Hierarchy: Parent-Child
After the pipeline, @xpulse/controller builds the hierarchy β
navItems with a parent key are nested as children[].
| // Declaration |
| this.navItem('index', { label: 'Products', position: 10 }); |
| this.navItem('chat', { label: 'Chat', position: 0, parent: 'products_index' }); |
| this.navItem('web', { label: 'Web', position: 1, parent: 'products_index' }); |
| // Resulting structure |
| { |
| key: 'products_index', |
| label: 'Products', |
| href: '/products', |
| children: [ |
| { key: 'products_chat', label: 'Chat', href: '/products/chat', children: [] }, |
| { key: 'products_web', label: 'Web', href: '/products/web', children: [] }, |
| ] |
| } |
Active State
@xpulse/controller marks the navItem whose href matches the current
route (req.path) as active: true.
In a hierarchy: when a child is active, the parent is also marked
active: true β useful for expanded sub-navigations.
Template Injection
Analogous to _route, _nav is automatically injected into all template data β
once during controller.init(), no manual work required per view.
| // Internally in @xpulse/controller |
| event.on('template:render:before', ({ data }) => { |
| data._nav = navigationRegistry.getItems(); // finished, hierarchical navItems |
| }); |
In the template:
| <nav> |
| {% for item in _nav %} |
| <a href="{{ item.href }}" |
| class="{{ item.active ? 'active' : '' }}" |
| target="{{ item.target }}"> |
| {{ item.label }} |
| </a> |
| {% if item.children.length %} |
| <ul> |
| {% for child in item.children %} |
| <li> |
| <a href="{{ child.href }}" |
| class="{{ child.active ? 'active' : '' }}"> |
| {{ child.label }} |
| </a> |
| </li> |
| {% endfor %} |
| </ul> |
| {% endif %} |
| {% endfor %} |
| </nav> |
Error Handling
Duplicate Key
The most common error: two navItem() declarations end up with the same key β
either because two controllers accidentally produce the same auto-generated key
(rare but possible), or because a custom key collides.
Behaviour: The second entry is discarded. The first always wins.
| [controller] navItem DUPLICATE key "chat" ignored |
| β src/controllers/support/chat.js @ index() |
| already registered by: src/controllers/tool/chat.js @ index() |
| Fix: use key option to set an explicit unique key |
This is a startup error that is immediately obvious β no silent data loss, no incorrect rendering.
Unknown Method
If navItem() is given a method name that does not exist on the controller,
a warning is issued and the entry is discarded.
| [controller] navItem UNKNOWN method "details" in InfoController |
| β src/controllers/info.js |
| Available methods: index, team, guide |
Unknown Parent Key
If a parent value references a key that does not exist after the full discovery,
a warning is issued β but the navItem is kept and treated as a top-level entry.
| [controller] navItem parent "products_index" not found for key "info_team" |
| β src/controllers/info.js @ team() |
| Item will be rendered as top-level nav entry |
This is not a fatal error β navigation continues to work, just without
the intended hierarchy. The developer has the chance to correct this in
the collected event.
Error Strategy Summary
| Error case | Behaviour | Log level |
|---|---|---|
| Duplicate key | Second entry discarded, first wins | warn |
| Unknown method | Entry discarded | warn |
| Unknown parent key | Entry kept, becomes top-level | warn |
navItem() outside define() |
Entry discarded | warn |
No error case throws an exception or aborts startup β navigation is non-critical. App startup continues, all errors are visible in the log.
Logging
@xpulse/controller logs all navigation-related operations via
@xpulse/logger. Log levels are chosen deliberately:
`debug` β Normal case
| [controller] navItem registered: info_index β GET /info "Info" position=10 |
| [controller] navItem registered: info_team β GET /info/team "Team" position=20 |
| [controller] navigation collected: 5 items |
| [controller] navigation ready: 5 items (2 top-level, 3 children) |
Everything that happens in the normal case runs at debug β
silent in production, visible in development with LOG_LEVEL=debug.
`warn` β Developer errors
All error-handling cases (see above) are logged at warn β
always visible, including in production, because they indicate a
configuration error that should be fixed.
`info` β Summary on startup
Once after controller:navigation:ready:
| [controller] Navigation ready β 5 items registered |
A brief summary that appears in the normal startup log and confirms that the navigation pipeline has completed.
Responsibility Overview
| Responsibility | Where |
|---|---|
| Which routes exist? | @xpulse/router |
| Which routes are nav-relevant? | @xpulse/controller β opt-in via navItem() |
| Filter / extend / sort | App code β via controller:navigation:collected |
Deliver _nav to templates |
@xpulse/controller β via template:render:before |
| Render and style nav | Template + @xpulse/theme |
@xpulse/theme never decides what is in the navigation β
it only renders what _nav provides.
Comparison with Jβ’Frame
| Jβ’Frame | xPulse |
|---|---|
@NavigationItem annotation (PHP Reflection) |
this.navItem() in define() |
NavigationManager.getItemsFromControllerAnnotations() |
Controller discovery collects navItems |
addNavigationItemToNavigation() after build |
controller:navigation:collected event |
Navigation object |
NavItem[] array (flat, then hierarchical) |
| In theming package (historical) | In @xpulse/controller (logically correct) |
The result is identical β the approach is event-driven instead of reflection-based.
Emitted Events (Overview)
| Event | Payload | When |
|---|---|---|
controller:navigation:collected |
{ items: NavItem[] } |
after discovery, before finalisation β mutable |
controller:navigation:ready |
{ items: NavItem[] } |
after finalisation β read-only |
Dependency on Existing Controller Events
Navigation events fire after controller:discovered:
| controller:discovered β all controllers found, routes registered |
| controller:navigation:collected β navItems collected, pipeline open |
| controller:navigation:ready β navigation finalised |