xPulse
🇩🇪 DE

Navigation – Component Concept

Status: CONCEPT · Erstellt März 2026 Übergeordnet: GLOBAL_concept-ecosystem.md, GLOBAL_concept-web.md Implementierung in: @xpulse/controller Inspiration: J•Frame Theming Component – NavigationManager + @NavigationItem Annotation


Problem

Der Router kennt nach der Controller-Discovery alle registrierten Routes – inklusive parametrisierter Routen (/info/:page?), API-Endpunkte (POST /contact), und interner Sub-Routen die nie als Navigation-Link erscheinen sollen.

Wer alle Routes blind als Navigation ausgibt, hat ein Filterproblem. Wer die Navigation manuell pflegt, hat ein Synchronisierungsproblem.


Lösung: Opt-in Navigation via `define()` + Event Pipeline

Navigation-Einträge werden im Controller deklariert – explizit, nicht automatisch. Nur was mit this.navItem() markiert ist, taucht in der Navigation auf.

Nach der Discovery läuft die Navigation durch eine Event-Pipeline die der App-Code nutzen kann um zu filtern, zu erweitern oder umzusortieren – bevor die fertige Navigation als _nav ins Template injiziert wird.


Konzept im Überblick

Controller Discovery
navItem() Deklarationen gesammelt ← Opt-in, direkt im Controller
controller:navigation:collected ← Event: App-Code kann filtern/erweitern
controller:navigation:ready ← Event: fertige Navigation, read-only
template:render:before ← _nav in Template-Daten injiziert
Template rendert <nav>

Opt-in im Controller

Navigation wird in define() deklariert – der gleiche Hook der auch Route-Overrides (overrideRoute, overrideMethod) enthält.

// 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() bleibt stumm – erreichbar, aber kein Nav-Eintrag
}
index() { ... } // → GET /info
team() { ... } // → GET /info/team
guide() { ... } // → GET /info/guide (kein navItem → kein Nav-Link)
}
// src/controllers/index.js
export default class IndexController extends Controller {
define() {
this.navItem('index', { label: 'Start', position: 0 });
}
index() { ... } // → GET /
}
// src/controllers/contact.js
export default class ContactController extends Controller {
define() {
this.navItem('index', { label: 'Kontakt', position: 30 });
// submit() ist POST – kein navItem, macht keinen Sinn
}
index() { ... } // → GET /contact
submit() { ... } // → POST /contact
}

this.navItem(method, options)
Option Typ Default Beschreibung
key string auto-generiert Eigener Key – überschreibt den auto-generierten
label string Methodenname (ucfirst) Anzeigename im Nav-Link
position number 0 Sortierreihenfolge (aufsteigend)
parent string '' Key eines anderen navItems → Sub-Navigation
icon string '' Optionaler Icon-Identifier (z.B. CSS-Klasse)
target string '_self' Link-Target (_self, _blank)

method verweist auf eine Methode des eigenen Controllers – der Router löst den korrekten href automatisch auf. Kein manuelles Pfad-Tippen.


Key-Konstruktion

Auto-generierter Key

Der Key wird aus dem Controller-Slug und dem Methodennamen zusammengebaut, getrennt durch _:

{controllerSlug}_{methodName}

Der Controller-Slug leitet sich aus dem Dateipfad relativ zu src/controllers/ ab – Verzeichnistrenner werden zu _, die Dateiendung fällt weg:

src/controllers/index.jscontrollerSlug: index
src/controllers/info.jscontrollerSlug: info
src/controllers/tool/chat.jscontrollerSlug: tool_chat
src/controllers/admin/user.jscontrollerSlug: admin_user

Daraus ergeben sich die Keys:

index.jsindex() → key: index_index
info.jsindex() → key: info_index
info.jsteam() → key: info_team
info.jsguide() → key: info_guide
tool/chat.jsindex() → key: tool_chat_index
tool/chat.jsguide() → key: tool_chat_guide
admin/user.jsindex() → key: admin_user_index
admin/user.jsedit() → key: admin_user_edit

Key zur Entwicklungszeit herausfinden

Als Entwickler brauchst du den Key z.B. wenn du parent in einem anderen Controller setzen willst. Es gibt drei Wege:

1. Aus dem Dateipfad ableiten (ohne Tool):

src/controllers/{path}/{file}.js → {path}_{file}_{method}
Unterstriche statt Slashes, kein .js

2. Logging beim Start@xpulse/controller loggt alle navItems auf debug-Level:

[controller] navItem registered: info_team → GET /info/team "Team"
[controller] navItem registered: info_index → GET /info "Info"

3. @xpulse/debug Collector (wenn installiert) – zeigt alle registrierten navItems in der Debug Toolbar / Web Profiler unter "Navigation".

Eigenen Key vergeben

Wenn der auto-generierte Key zu lang oder unhandlich ist, kann ein eigener gesetzt werden:

// src/controllers/tool/chat.js
define() {
this.navItem('index', { key: 'chat', label: 'Chat', position: 10 });
}

Der custom Key 'chat' ersetzt 'tool_chat_index' vollständig – in der Datenstruktur, im Event-Payload und als parent-Referenz.

// Ein anderer Controller referenziert den custom Key als parent:
this.navItem('index', { label: 'Chat Guide', parent: 'chat' });

Vorsicht: Custom Keys liegen in der Verantwortung des Entwicklers. Kollidiert ein custom Key mit einem anderen auto-generierten oder custom Key, greift das Error Handling (siehe unten).


Was die Event-Pipeline bekommt und weitergibt:

{
key: 'info_team', // eindeutiger Schlüssel: controllerKey_methodName
label: 'Team',
href: '/info/team', // vom Router aufgelöst
position: 20,
parent: 'info_index', // '' wenn kein Parent
icon: '',
target: '_self',
active: false, // true wenn aktuelle Route matcht
children: [], // NavItems mit passendem parent-Key
}

Event Pipeline

`controller:navigation:collected`

Wird gefeuert nachdem alle Controller-Discovery abgeschlossen ist und alle navItem() Deklarationen gesammelt wurden – bevor die Navigation finalisiert wird.

// Payload
{ items: NavItem[] }

Der Handler kann das items-Array mutieren – filtern, ergänzen, umsortieren. Der Rückgabewert wird ignoriert; Mutation ist der Weg.

// Beispiel: Filtern
event.on('controller:navigation:collected', ({ items }) => {
// Admin-Link nur anzeigen wenn eingeloggt
const filtered = items.filter(item => {
if (item.key === 'admin_index') return isAuthenticated();
return true;
});
items.splice(0, items.length, ...filtered);
});
// Beispiel: Externen Link ergänzen
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: [],
});
});
// Beispiel: Umsortieren
event.on('controller:navigation:collected', ({ items }) => {
items.sort((a, b) => a.position - b.position);
});

`controller:navigation:ready`

Wird gefeuert nachdem die Pipeline abgeschlossen ist – Navigation ist finalisiert, Parent-Child-Struktur aufgebaut, sortiert.

Read-only. Mutation hier hat keinen Effekt mehr.

// Payload
{ items: NavItem[] } // bereits hierarchisch – items mit children[]

Nützlich für Logging, Debugging oder externe Systeme die die fertige Navigation kennen müssen (z.B. Sitemap-Generator, @xpulse/debug Collector).


Hierarchie: Parent-Child

Nach der Pipeline baut @xpulse/controller die Hierarchie auf – NavItems mit parent-Key werden als children[] eingehängt.

// Deklaration
this.navItem('index', { label: 'Produkte', position: 10 });
this.navItem('chat', { label: 'Chat', position: 0, parent: 'produkte_index' });
this.navItem('web', { label: 'Web', position: 1, parent: 'produkte_index' });
// Resultierende Struktur
{
key: 'produkte_index',
label: 'Produkte',
href: '/produkte',
children: [
{ key: 'produkte_chat', label: 'Chat', href: '/produkte/chat', children: [] },
{ key: 'produkte_web', label: 'Web', href: '/produkte/web', children: [] },
]
}

Active State

@xpulse/controller markiert das NavItem dessen href mit der aktuellen Route (req.path) übereinstimmt als active: true.

Bei Hierarchie: wenn ein Child active ist, wird auch das Parent als active: true markiert – praktisch für aufgeklappte Sub-Navigationen.


Template-Injection

Analog zu _route wird _nav automatisch in alle Template-Daten injiziert – einmalig beim controller.init(), kein manueller Aufwand pro View.

// Intern in @xpulse/controller
event.on('template:render:before', ({ data }) => {
data._nav = navigationRegistry.getItems(); // fertige, hierarchische NavItems
});

Im 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

Doppelter Key

Der häufigste Fehlerfall: zwei navItem()-Deklarationen landen beim gleichen Key – entweder weil zwei Controller zufällig den gleichen auto-generierten Key produzieren (selten aber möglich), oder weil ein custom Key kollidiert.

Verhalten: Der zweite Eintrag wird verworfen. Der erste gewinnt immer.

[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

Das ist ein Startup-Fehler der sofort auffällt – kein stiller Datenverlust, kein falsches Rendering.

Unbekannte Methode

Wird in navItem() ein Methodenname angegeben der im Controller nicht existiert, wird gewarnt und der Eintrag verworfen.

[controller] navItem UNKNOWN method "details" in InfoController
src/controllers/info.js
Available methods: index, team, guide

Unbekannter Parent-Key

Referenziert ein parent-Wert einen Key der nach der kompletten Discovery nicht existiert, wird gewarnt – das navItem bleibt aber erhalten und wird als Top-Level-Eintrag behandelt.

[controller] navItem parent "produkte_index" not found for key "info_team"
→ src/controllers/info.js @ team()
Item will be rendered as top-level nav entry

Das ist kein fataler Fehler – die Navigation funktioniert weiter, nur ohne die gewünschte Hierarchie. Gibt dem Entwickler die Chance das im collected-Event noch zu korrigieren.

Zusammenfassung Error-Strategie

Fehlerfall Verhalten Log-Level
Doppelter Key Zweiter Eintrag verworfen, erster gewinnt warn
Unbekannte Methode Eintrag verworfen warn
Unbekannter Parent-Key Eintrag bleibt, wird Top-Level warn
navItem() außerhalb define() Eintrag verworfen warn

Kein Fehlerfall wirft eine Exception oder bricht den Start ab – Navigation ist non-critical. Der App-Start läuft durch, alle Fehler sind im Log sichtbar.


Logging

@xpulse/controller loggt alle Navigation-relevanten Vorgänge über @xpulse/logger. Die Log-Level sind bewusst gewählt:

`debug` – Normalfall

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

Alles was im Normalfall passiert läuft auf debug – im Produktionsbetrieb stumm, in der Entwicklung mit LOG_LEVEL=debug sichtbar.

`warn` – Entwicklerfehler

Alle Error-Handling-Fälle (siehe oben) werden auf warn geloggt – immer sichtbar, auch im Produktionsbetrieb, weil sie auf einen Konfigurationsfehler hinweisen der behoben werden sollte.

`info` – Summary beim Start

Einmalig nach controller:navigation:ready:

[controller] Navigation ready – 5 items registered

Knappes Summary das im normalen Startup-Log erscheint und bestätigt dass die Navigation-Pipeline durchgelaufen ist.


Verantwortung Wo
Welche Routes gibt es? @xpulse/router
Welche Routes sind Nav-relevant? @xpulse/controller – Opt-in via navItem()
Filtern / Erweitern / Sortieren App-Code – via controller:navigation:collected
_nav ins Template liefern @xpulse/controller – via template:render:before
Nav rendern und stylen Template + @xpulse/theme

@xpulse/theme entscheidet nie was in der Navigation steht – es rendert nur was _nav liefert.


Vergleich J•Frame

J•Frame xPulse
@NavigationItem Annotation (PHP Reflection) this.navItem() in define()
NavigationManager.getItemsFromControllerAnnotations() Controller-Discovery sammelt navItems
addNavigationItemToNavigation() nach Build controller:navigation:collected Event
Navigation Objekt NavItem[] Array (flach, dann hierarchisch)
Im Theming-Package (historisch) In @xpulse/controller (logisch korrekt)

Das Ergebnis ist identisch – der Weg ist event-driven statt reflection-based.


Gefeuerte Events (Übersicht)

Event Payload Wann
controller:navigation:collected { items: NavItem[] } nach Discovery, vor Finalisierung – mutable
controller:navigation:ready { items: NavItem[] } nach Finalisierung – read-only

Abhängigkeit zu bestehenden Controller-Events

Navigation-Events feuern nach controller:discovered:

controller:discovered ← alle Controller gefunden, Routes registriert
controller:navigation:collected ← navItems gesammelt, Pipeline offen
controller:navigation:ready ← Navigation finalisiert
de/concept/navigation.md 2026-03-30