xPulse
πŸ‡¬πŸ‡§ EN

Navigation – Component Concept

Status: CONCEPT Β· Created March 2026 Parent: GLOBAL_concept-ecosystem.md, GLOBAL_concept-web.md Implemented in: @xpulse/controller Inspiration: Jβ€’Frame Theming Component – NavigationManager + @NavigationItem Annotation


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
}

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).


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
en/concept/navigation.md 2026-03-30