BoxLang 🚀 A New JVM Dynamic Language Learn More...
Breadcrumb-Buddy is a lightweight ColdBox module that simplifies breadcrumb navigation for your web applications. It generates dynamic breadcrumbs based on ColdBox events, supports aliases for intuitive usage, and allows recursive entity hierarchies (e.g., nested pages). It's plug-and-play with minimal setup, perfect for blogs, CMS, or any app needing clear navigation trails.
This module was inspired from the very well thought out Laravel-Breadcrumbs package. This module is not a port of that package. It was designed to bring similar breadcrumb functionality to ColdBox.
main.index, posts.show)
or regex patterns.trail.parent("home") instead of main.index.Home > Category > Subcategory > Page).breadcrumbs()
Install Breadcrumb-Buddy via CommandBox:
box install breadcrumb-buddy
This adds the module to your ColdBox app under
modules/breadcrumb-buddy, by convention.
views/layouts/Main.cfm):// Somewhere in your layout or view
#breadcrumbs().render()#
Customize Rules in
config/ColdBox.cfc or
/config/modules/breadcrumb-buddy.cfc to override
default breadcrumbs. See configuration section below for details.
Styling the Output: By default, the module will
output the breadcrumbs in a simple HTML list. You can customize
the output by creating your own view and updating the
configuration. You can style the breadcrumbs using SASS/CSS
classes or frameworks like Bootstrap. Example HTML output after
calling render():
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="./">Home</a></li>
<li class="breadcrumb-item"><a href="./posts/">Blog</a></li>
<li class="breadcrumb-item active" aria-current="page">My Blog Post</li>
</ol>
</nav>
Breadcrumb-Buddy is configured in
config/ColdBox.cfc or
/config/modules/breadcrumb-buddy.cfc. The following
settings are available:
breadcrumbs/index).breadcrumb-buddy).main.index). Set it
to "" to disable the fallback.true, an error
inside one of your rules is rethrown so you see it right away. When
false (the default), the error is logged and the page
keeps rendering. Tip: turn this on in development.parent() calls. (e.g.,
home: main.index).settings = {
// Override view if desired
"view" = "breadcrumbs/index",
// Override view module if desired
"viewModule" = "breadcrumb-buddy",
// if no matching rule found, default to this event
"defaultEvent": "main.index",
// log rule errors and keep rendering (true rethrows after logging)
"throwOnError": false,
// Event-Based breadcrumb rules
"events" = {
"main.index": function( trail, event, rc, prc ) {
trail.push( "Home", event.buildLink( "" ) );
}
},
// Aliases for trail.parent() calls
"aliases": {
"home" = "main.index"
}
};
Add to config/ColdBox.cfc or /config/modules/breadcrumb-buddy.cfc:
// Coldbox.cfc example configuration
moduleSettings = {
"breadcrumb-buddy": {
// Override view if desired
"view" = "breadcrumbs/index",
// Override view module if desired
"viewModule" = "breadcrumb-buddy",
// if no matching rule found, default to this event
"defaultEvent": "main.index",
// log rule errors and keep rendering (true rethrows after logging)
"throwOnError": false,
// Event-Based breadcrumb rules
"events" = {
"main.index": function( trail, event, rc, prc ) {
trail.push( "Home", event.buildLink( "" ) );
},
// Custom event rules for blog post listing page
"posts.index": function( trail, event, rc, prc ) {
trail.parent( "home" )
.push( "Blog", event.buildLink( "blog" ) );
},
// Custom event rules for blog post show page
"posts.show": function( trail, event, rc, prc ) {
trail.parent( "home" )
.push( "Blog", event.buildLink( "blog" ) )
.push( prc.post.getName(), event.buildLink( "posts/#prc.post.getId()#" ) );
}
},
// Aliases for trail.parent() calls
"aliases": {
"home" = "main.index"
}
};
};
Rules are closures in settings.events, keyed by event
names (or regex patterns). Each closure receives:
trail: BreadcrumbTrail instance to build crumbs.event: ColdBox event object.rc: Request collectionprc: Private collection.The beauty of using closures like this is that you are only limited by your own creativity. You can use any entity, structure, or logic to build your breadcrumb trail.
Example rule:
"posts.index": function( trail, event, rc, prc ) {
// Inherit home crumbs
trail.parent( "home" )
// Push the blog post listing page
.push( "Blog", event.buildLink( "blog" ) );
},
"posts.show": function( trail, event, rc, prc ) {
// Inherit Blog crumbs
trail.parent( "posts.index" )
// Push the current post
.push( prc.post.getName(), event.buildLink( "posts/#prc.post.getId()#" ) );
}
Most of the time, the key is simply your event name. When breadcrumbs
are generated for posts.show, Breadcrumb-Buddy looks for
a rule with the key posts.show. That's it.
Want one rule to cover several events? Use a regex pattern as the key:
"events" = {
// one rule for admin:reports.daily, admin:reports.monthly, etc.
"admin:reports\..*" = function( trail, event, rc, prc ) {
trail.parent( "home" )
.push( "Reports", event.buildLink( "admin/reports" ) );
}
}
A few simple guarantees:
posts.show and posts\..*, the
event posts.show uses the exact rule.posts\..* matches posts.show, but the key
posts will NOT match posts.show. If you
want "everything under posts", write posts\..*.defaultEvent
rule runs (by default, your main.index rule).Aliases let you use friendly names:
trail.parent( "home" ); // Resolves to main.index
Define in settings.aliases:
"aliases": {
"home": "main.index",
"blog": "posts.index"
}
Note: Aliases work with trail.parent(),
not trail.push(). This is by design: push()
always adds exactly one literal crumb, so a crumb whose name happens
to match an alias (like "Home" and home) can
never surprise you. When you want to build on another event's crumbs,
use parent().
Each rule closure receives a trail object with methods
to build breadcrumbs:
trail.push( name, link ):
name: Text (e.g., "Home Page").link: Optional URL.trail.parent( eventName ):
parent("posts.index")).parent("home")).Building your own ColdBox module (a CMS, an admin panel, a blog
module)? Your module can ship its own breadcrumb rules — and, if it
needs to, its own breadcrumb template and its own fallback. Just add a
breadcrumb-buddy key to your module's settings in its ModuleConfig.cfc:
// mymodule/ModuleConfig.cfc
function configure(){
settings = {
// ... your module's own settings ...
// Breadcrumbs this module contributes
"breadcrumb-buddy" = {
"events" = {
// module events are named "moduleName:handler.action"
"mymodule:dashboard.index" = function( trail, event, rc, prc ) {
trail.parent( "home" )
.push( "Dashboard", event.buildLink( "mymodule/dashboard" ) );
}
},
"aliases" = {
// ALWAYS prefix an alias with your module name (see below)
"mymodule:dashboard" = "mymodule:dashboard.index"
},
// Optional. These apply ONLY to your module's own events.
// Render your screens in your own template. `viewModule` defaults to
// your module name, so you can usually leave it out.
"view" = "breadcrumbs/index",
"viewModule" = "mymodule",
// Where YOUR unmapped screens land. The app's own default is untouched.
"defaultEvent" = "mymodule:dashboard.index"
}
};
}
Breadcrumb-Buddy finds these automatically. No extra wiring needed, and it doesn't matter which module loads first.
A module can never change what the app does. view,
viewModule and defaultEvent in your
contribution are scoped to your own mymodule:* events —
an unmapped app event still lands on the app's
defaultEvent, and the rest of the site still renders in
the app's template.
Settings resolve lowest to highest:
render( view = "...", viewModule =
"..." ) argument| Key | App config | Your module | App's modules override |
|---|---|---|---|
events, aliases
| yes | yes | — |
view, viewModule, defaultEvent
| yes | yes | yes |
throwOnError
| app only | ignored, logs a warning | — |
Anything else in a contribution is ignored and logs a warning, so a typo tells you instead of quietly doing nothing.
Installed a module whose crumbs don't fit your site? Point it somewhere else from your own config. This is the only way one module's config can affect another, and it wins per key:
moduleSettings = {
"breadcrumb-buddy": {
"modules" = {
// make the admin module's crumbs render in one of OUR views
// (an empty viewModule means "a view in the app")
"admin" = { "view" = "breadcrumbs/compact", "viewModule" = "" }
}
}
};
dashboard is really a claim on the whole app: it will
lose to the app, or beat another module, depending on who got there
first. mymodule:dashboard cannot collide with anyone.
Breadcrumb-Buddy logs a warning if you forget.parent(
"home" )), and the app can use aliases
contributed by modules.mymodule:dashboard.index), so those never clash.Sometimes config isn't enough — maybe your module builds its rules
from a database. You can register rules from code anywhere you can
reach the service (handlers, onDIComplete, interceptors):
breadcrumbs().register( "mymodule:pages.view", function( trail, event, rc, prc ) {
trail.parent( "home" ).push( "Pages", event.buildLink( "mymodule/pages" ) );
} );
breadcrumbs().registerAlias( "pages", "mymodule:pages.view" );
Runtime registrations win over everything else, and registering the same event again simply replaces the old rule.
Need full control for a single page? Skip the rules and pass the
crumbs straight to render():
#breadcrumbs().render( breadcrumbs = [
{ "name": "Home", "link": event.buildLink( "" ) },
{ "name": "Something Special", "link": "" }
] )#
For a blog at /posts/123 (events:
posts.index, posts.show ):
"posts.show": function( trail, event, rc, prc ) {
// Inherit the home crumbs using an alias
trail.parent( "home" )
// Push the blog post listing page
.push( "Blog", event.buildLink( "posts" ) )
// Push the current post
.push( prc.post.getName(), event.buildLink( "posts.#prc.post.getId()#" ) );
}
Crumbs:
[
{ "name": "Home", "link": "/" },
{ "name": "Blog", "link": "/posts" },
{ "name": "My Post", "link": "/posts/123" }
]
For /about-us/jobs (event: pages.show):
In the following example, we are using a page entity to
build the breadcrumb trail. The page entity represents a
hierarchical (parent/child) relationship that allows us to traverse
the page hierarchy from the current page to the root page. The
page object has a method getParent() that
returns the parent page object, and a method hasParent()
that checks if the page has a parent. The isLoaded()
method checks if the page object is loaded. Substitute the
page object with your own entity or structure as needed.
// Pages
"pages.show" = function( trail, event, rc, prc ) {
// prepend the parent page crumbs
trail.parent( "home" );
// create a variable to hold the page hierarchy (current to root)
var pageHierarchy = [ page ]; // add the current page to the hierarchy
var currentPage = prc.page; // current page object
var rootUrl = event.buildLink( "" );
var hasParents = currentPage.hasParent(); // check if the page has a parent
// Collect pages from current to root
while( hasParents ) {
// get the parent page object
var parent = currentPage.getParent();
if( parent.isLoaded() ) {
// add the parent page to the hierarchy
pageHierarchy.append( parent );
// set the current page to the parent page
currentPage = parent;
// check if the parent page has a parent
hasParents = parent.hasParent();
} else {
// stop if the parent page is not loaded
hasParents = false;
}
}
// Build breadcrumb trail in correct order (root to current)
var urlBuilder = rootUrl;
// reverse the page hierarchy to get the correct order
pageHierarchy.reverse().each( function( page ) {
urlBuilder &= page.slug & "/";
trail.push( page.name, urlBuilder );
} );
},
Crumbs:
[
{ "name": "Home", "link": "/" },
{ "name": "About Us", "link": "/about-us" },
{ "name": "Jobs", "link": "/about-us/jobs" }
]
You can define a rule for handling 404 pages or other errors by
matching the event name you use for your error handling. For example,
if you have a custom error event like
errors.onMissingPage, you can define a rule for it in
your configuration.
Coldbox Gotcha: If you use an around handler pattern
to catch errors, you may need to set the event object in
the rc collection to ensure the breadcrumbs are built
correctly. This is because calling runEvent() does not
change the current event for the request.
There are several ways to work around this behavior. One way is to
use the event.overrideEvent() method to set the event in
the current request. Side note: overrideEvent() will
bypass the event cache for the current event, which may or may not be
desirable based on your use case. The second option is to simply set
the event in the rc collection. This will not bypass the
event cache, and will still trigger the desired breadcrumb rule.
// Common aroundHandler pattern used in base handlers
function aroundHandler( event, targetAction, eventArguments, rc, prc ) {
try{
// prepare arguments for action call
var args = {
event = arguments.event,
rc = arguments.rc,
prc = arguments.prc
};
structAppend( args, eventArguments );
// execute the action now
return arguments.targetAction( argumentCollection=args );
// Catch 404 errors!
} catch ( NotFound e ) {
// option 1: Override the event (will bypass the event cache)
event.overrideEvent( "errors.onMissingPage" ); // bypasses event cache on the current event
// option 2: Set the event in the rc (will not bypass the event cache)
rc.event = "errors.onMissingPage";
return runEvent(
event = "errors.onMissingPage",
eventArguments = {
"exception": e
}
);
}
}
For a missing page:
"errors.onMissingPage": function( trail, event, rc, prc ) {
trail.push( "Home", event.buildLink( "" ) )
.push( "Page Not Found", "" );
}
Crumbs:
[
{ "name": "Home", "link": "/" },
{ "name": "Page Not Found", "link": "" }
]
Check out the sample application in the test-harness folder.
Won't do: trail.push() accepting
aliases. Since alias lookups are case-insensitive and aliases usually
mirror crumb names (home / "Home"), an
alias-aware push() could silently expand a literal crumb
into a whole trail. push() stays literal; use
trail.parent() (which accepts aliases) to build on
another event's crumbs.
Do you have any ideas for improving this module? Feel free to submit an issue or, even better, a pull request! Don't forget to add tests for your changes.
This module was created by Angry Sam Productions, a California-based web development company. We're passionate about giving back to the dev community through open source because we believe sharing knowledge builds a stronger, better-connected world. If you're interested in contracting us for your next project or learning more, feel free to reach out.
From the root of the project in CommandBox:
# install the test harness dependencies (first time only)
box run-script install:dependencies
# start a test server (or start:lucee6, start:2023, start:boxlang)
box run-script start:lucee5
# run the full test suite from the command line
box testbox run
You can also open
http://localhost:60320/tests/runner.cfm in your browser
to run the suite there.
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
view, viewModule and defaultEvent to its own events, so a module (a CMS admin, say) can render its screens in its own template and give its own unmapped screens their own fallback — without changing anything for the host app. Previously a contribution could only carry events and aliases, so a module that needed its own template had no way to ask for one, and the only thing that worked was overwriting the app's global settings — which quietly broke the host's breadcrumbs.viewModule defaults to the contributing module's own name when a module declares a view and nothing else.modules setting for the app: moduleSettings[ "breadcrumb-buddy" ].modules.<moduleName> overrides view / viewModule / defaultEvent for a module you didn't write. Wins per key — the app always gets the last word.BreadcrumbService.render() accepts view and viewModule arguments to override the template for a single call.BreadcrumbRegistry.getModuleSettings( eventName ) resolves the settings that apply to one event.BreadcrumbRegistry.getDefaultRule() takes an optional eventName, so an unmapped module event can fall back to that module's own default. Called with no argument it uses the app's defaultEvent, exactly as before.throwOnError (app-scoped), or a key breadcrumb-buddy does not read, or an alias that is not prefixed with its own module name, gets a logger.warn() instead of silence. Nothing throws, and nothing that used to work stops working.view / viewModule / defaultEvent were silently discarded from contributions before this version, so no existing module can have meant anything by them.^...$). A key like posts no longer substring-matches posts.index; write posts\..* for that. Exact keys never regex-match.trail.pushEvent() method (a misleadingly-named alias for parent() — it prepended, not appended). If you used it, rename the call to trail.parent().breadcrumb-buddy key in its own ModuleConfig.cfc settings with events and aliases. Breadcrumb-buddy discovers them automatically via a new BreadcrumbRegistry (lazy, load-order independent, kept in sync by a postModuleLoad/postModuleUnload interceptor). Precedence: runtime registrations > app config > module contributions (alphabetical); conflicts are logged.breadcrumbs().register( event, rule ) and breadcrumbs().registerAlias( alias, target ).throwOnError setting (default false): rule errors are always logged via LogBox; when true they are rethrown.trail.parent() chains (A → B → A) are now detected and throw BreadcrumbBuddy.CircularParentException instead of recursing forever. With throwOnError=false the cycle is logged and the page keeps rendering.testmodule sample module demonstrating module-contributed breadcrumbs, plus specs for the registry, matching semantics, error handling, and cycle guard.writeDump() into the page output; they are logged via LogBox.box.json boxlang scripts referenced a non-existent server file ([email protected] → [email protected]).[email protected] module alias casing fixed for case-sensitive systems.BreadcrumbTrail.
$
box install breadcrumb-buddy