diff --git a/strategy/AbstractTemplateStrategy.cfc b/strategy/AbstractTemplateStrategy.cfc
index 1f2b1b1..93ec5de 100644
--- a/strategy/AbstractTemplateStrategy.cfc
+++ b/strategy/AbstractTemplateStrategy.cfc
@@ -650,6 +650,9 @@ abstract component accessors="true" implements="IStrategy" {
// If this is a class, output it
if ( structKeyExists( itemValue, "$#arguments.classTerm#" ) ) {
+ if ( structIsEmpty( itemValue[ "$#arguments.classTerm#" ] ) ) {
+ continue;
+ }
var linkData = itemValue[ "$#arguments.classTerm#" ];
writeOutput( "
" );
writeOutput( item );
diff --git a/strategy/CommandBox/CommandBoxStrategy.cfc b/strategy/CommandBox/CommandBoxStrategy.cfc
index 39e06da..355962f 100644
--- a/strategy/CommandBox/CommandBoxStrategy.cfc
+++ b/strategy/CommandBox/CommandBoxStrategy.cfc
@@ -78,14 +78,24 @@ component extends="docbox.strategy.api.HTMLAPIStrategy" {
*/
function init(
required outputDir,
- string projectTitle = "Untitled"
+ string projectTitle = "Untitled",
+ string theme = "default"
){
super.init( argumentCollection = arguments );
// Override the parent's theme-based paths with CommandBox-specific paths
- variables.TEMPLATE_PATH = "/docbox/strategy/CommandBox/resources/templates";
- variables.ASSETS_PATH = "/docbox/strategy/api/themes/frames/resources/static";
+ variables.TEMPLATE_PATH = "/docbox/strategy/CommandBox/themes/#variables.theme#/resources/templates";
+ variables.ASSETS_PATH = "/docbox/strategy/api/themes/frames/resources/static";
+ variables.COMMANDBOX_STATIC_PATH = "/docbox/strategy/CommandBox/themes/#variables.theme#/resources/static";
+ // Theme CSS assets are not laid out consistently across themes.
+ // The frames theme stores its stylesheet directly under /resources/static
+ // instead of /resources/static/css, so resolve the source path accordingly.
+ if ( variables.theme == "frames" ) {
+ variables.THEME_CSS_PATH = "/docbox/strategy/api/themes/frames/resources/static";
+ } else {
+ variables.THEME_CSS_PATH = "/docbox/strategy/api/themes/#variables.theme#/resources/static/css";
+ }
return this;
}
@@ -199,13 +209,27 @@ component extends="docbox.strategy.api.HTMLAPIStrategy" {
index++;
}
- // copy over the static assets
+ // 1. Copy frames theme assets (highlighter, jstree, root stylesheet for standalone pages)
directoryCopy(
expandPath( variables.ASSETS_PATH ),
getOutputDir(),
true
);
+ // 2. Overlay CommandBox-specific JS from its own theme (js/app.js)
+ directoryCopy(
+ expandPath( variables.COMMANDBOX_STATIC_PATH & "/js" ),
+ getOutputDir() & "/js",
+ true
+ );
+
+ // 3. Overlay shared CSS from the HTMLAPIStrategy's chosen theme (css/stylesheet.css)
+ directoryCopy(
+ expandPath( variables.THEME_CSS_PATH ),
+ getOutputDir() & "/css",
+ true
+ );
+
// write the index template
var args = {
path : getOutputDir() & "/index.html",
diff --git a/strategy/CommandBox/resources/templates/inc/common.cfm b/strategy/CommandBox/resources/templates/inc/common.cfm
deleted file mode 100644
index 8610230..0000000
--- a/strategy/CommandBox/resources/templates/inc/common.cfm
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/resources/templates/inc/nav.cfm b/strategy/CommandBox/resources/templates/inc/nav.cfm
deleted file mode 100644
index 1021719..0000000
--- a/strategy/CommandBox/resources/templates/inc/nav.cfm
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/resources/templates/index.cfm b/strategy/CommandBox/resources/templates/index.cfm
deleted file mode 100644
index 6362275..0000000
--- a/strategy/CommandBox/resources/templates/index.cfm
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
- #arguments.projecttitle# Command API Documentation
-
-
-
-
-
-
-
- Frame Alert
-
-
- This document is designed to be viewed using the frames feature. If you see this message, you are using a non-frame-capable web client.
-
- Link toNon-frame version.
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/resources/templates/overview-frame.cfm b/strategy/CommandBox/resources/templates/overview-frame.cfm
deleted file mode 100644
index 46b8bae..0000000
--- a/strategy/CommandBox/resources/templates/overview-frame.cfm
+++ /dev/null
@@ -1,157 +0,0 @@
-
- // Build out data
- variables.namespaces = {};
- variables.topLevel = {};
-
- // Loop over commands
- for( local.row in qMetaData ) {
- // Skip our template CFC
- if( row.name == 'CommandTemplate' ) {
- continue;
- }
-
- local.command = row.command;
- local.namespaceParts = listToArray( row.namespace, ' ' );
-
- // Set "deep" struct to create nested data
- local.link = replace( row.package, ".", "/", "all") & '/' & row.name & '.html';
- local.packagelink = replace( row.package, ".", "/", "all") & '/package-summary.html';
- local.searchList = row.command;
- if( !isNull( row.metadata.aliases ) && len( row.metadata.aliases ) ) {
- searchList &= ',' & row.metadata.aliases;
- }
-
- // Determine which tree to use (top level commands vs namespaced commands)
- local.targetTree = ( listLen( command, ' ' ) == 1 ? variables.topLevel : variables.namespaces );
-
- // Set the package link on the root
- local.targetTree[ "$link" ] = local.packagelink; // Navigate/create nested namespace structure
- local.currentNode = local.targetTree;
- for( local.namespacePart in local.namespaceParts ) {
- if( !structKeyExists( local.currentNode, local.namespacePart ) ) {
- local.currentNode[ local.namespacePart ] = {};
- }
- local.currentNode = local.currentNode[ local.namespacePart ];
- }
-
- // Create command entry
- if( !structKeyExists( local.currentNode, local.row.name ) ) {
- local.currentNode[ local.row.name ] = {};
- }
-
- local.currentNode[ local.row.name ][ "$command" ] = {};
-
- // Add link and searchList for non-help commands
- if( row.name != 'help') {
- local.currentNode[ row.name ][ "$command" ].link = local.link;
- local.currentNode[ row.name ][ "$command" ].searchList = local.searchList;
- }
- }
- // writeDump( variables.topLevel );abort;
-
-
-
-
-
- #arguments.projectTitle# overview
-
-
-
-
-
-
- #arguments.projecttitle#
-
-
-
-
-
-
-
- #writeItems( namespaces, "namespace", "command" )#
-
- System Commands
-
-
- #writeItems( topLevel, "namespace", "command" )#
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/resources/templates/overview-summary.cfm b/strategy/CommandBox/resources/templates/overview-summary.cfm
deleted file mode 100644
index b8fc34f..0000000
--- a/strategy/CommandBox/resources/templates/overview-summary.cfm
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
- Overview - #arguments.projectTitle#
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/resources/templates/package-summary.cfm b/strategy/CommandBox/resources/templates/package-summary.cfm
deleted file mode 100644
index d3ed537..0000000
--- a/strategy/CommandBox/resources/templates/package-summary.cfm
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
- #arguments.projectTitle# #arguments.namespace#
-
-
-
-
-
-
-
- #arguments.namespace#
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Commands
-
-
-
-
-
- #command#
-
-
-
- #listgetat( meta.hint, 1, chr(13)&chr(10)&'.' )#
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/strategy/CommandBox/themes/default/resources/static/js/app.js b/strategy/CommandBox/themes/default/resources/static/js/app.js
new file mode 100644
index 0000000..c3177cb
--- /dev/null
+++ b/strategy/CommandBox/themes/default/resources/static/js/app.js
@@ -0,0 +1,289 @@
+// CommandBox Documentation SPA - Alpine.js Application
+// Replaces the frameset layout with an accessible single-page experience.
+function commandApp() {
+ return {
+ // ── State ──────────────────────────────────────────────────────────
+ currentView : "overview",
+ currentCommand : null,
+ currentNamespace : null,
+ namespaces : [],
+ topLevel : [],
+ allCommands : [],
+ searchQuery : "",
+ searchResults : [],
+ selectedSearchIndex : 0,
+ commandFilter : "",
+ expandedNamespaces : [],
+ sidebarCollapsed : false,
+ contentLoading : false,
+ contentHtml : "",
+ parentNamespace : null,
+ systemView : false,
+ _navigatingProgrammatically : false,
+
+ // ── Initialization ──────────────────────────────────────────────────
+ async init() {
+ if ( window.COMMANDBOX_NAV_DATA ) {
+ this.namespaces = window.COMMANDBOX_NAV_DATA.namespaces || [];
+ this.topLevel = window.COMMANDBOX_NAV_DATA.topLevel || [];
+ this.allCommands = window.COMMANDBOX_NAV_DATA.allCommands || [];
+ } else {
+ console.error( "COMMANDBOX_NAV_DATA not found. Ensure data/navigation.js is loaded." );
+ }
+
+ // Handle direct links and back/forward navigation
+ this.handleUrlHash();
+ window.addEventListener( "hashchange", () => {
+ // Ignore hash changes that we triggered ourselves inside loadCommand()
+ if ( this._navigatingProgrammatically ) {
+ this._navigatingProgrammatically = false;
+ return;
+ }
+ this.handleUrlHash();
+ } );
+ },
+
+ // ── URL Hash Routing ────────────────────────────────────────────────
+ handleUrlHash() {
+ const hash = window.location.hash.slice( 1 );
+ if ( !hash ) {
+ this.showOverview();
+ return;
+ }
+ // Hash is the link path minus ".html" (e.g. "commandbox/commands/server/start")
+ const link = hash + ".html";
+ const cmd = this.allCommands.find( c => c.link === link );
+ if ( cmd ) {
+ this.loadCommand( cmd );
+ } else {
+ this.showOverview();
+ }
+ },
+
+ // ── Views ────────────────────────────────────────────────────────────
+ showOverview() {
+ this.currentView = "overview";
+ this.currentCommand = null;
+ this.currentNamespace = null;
+ this.parentNamespace = null;
+ this.systemView = false;
+ this.contentHtml = "";
+ history.replaceState( null, "", window.location.pathname );
+ this.$nextTick( () => this.scrollContentTop() );
+ },
+
+ showNamespace( ns, parentNs = null ) {
+ if ( !ns ) return;
+ // Auto-detect parent namespace from fullNamespace (e.g. "config sync" → parent "config")
+ // when it isn't explicitly passed (e.g. sidebar navigation via Alpine x-for scope)
+ if ( !parentNs && ns.fullNamespace && ns.fullNamespace.includes( " " ) ) {
+ const parentName = ns.fullNamespace.split( " " )[ 0 ];
+ parentNs = this.namespaces.find( n => n.name === parentName ) || null;
+ }
+ this.currentView = "namespace";
+ this.currentNamespace = ns;
+ this.parentNamespace = parentNs || null;
+ this.currentCommand = null;
+ this.contentHtml = "";
+ // Expand the correct sidebar key(s)
+ if ( parentNs ) {
+ // Child namespace: expand parent AND parent/child key
+ if ( !this.expandedNamespaces.includes( parentNs.name ) ) {
+ this.expandedNamespaces.push( parentNs.name );
+ }
+ const childKey = parentNs.name + "/" + ns.name;
+ if ( !this.expandedNamespaces.includes( childKey ) ) {
+ this.expandedNamespaces.push( childKey );
+ }
+ } else {
+ this.parentNamespace = null;
+ // Top-level namespace
+ if ( !this.expandedNamespaces.includes( ns.name ) ) {
+ this.expandedNamespaces.push( ns.name );
+ }
+ }
+ this.$nextTick( () => this.scrollContentTop() );
+ },
+
+ showSystemCommands() {
+ this.currentView = "system";
+ this.currentCommand = null;
+ this.currentNamespace = null;
+ this.parentNamespace = null;
+ this.contentHtml = "";
+ if ( !this.expandedNamespaces.includes( "__system__" ) ) {
+ this.expandedNamespaces.push( "__system__" );
+ }
+ this.$nextTick( () => this.scrollContentTop() );
+ },
+
+ async loadCommand( cmd ) {
+ if ( !cmd || !cmd.link ) return;
+
+ this.currentView = "command";
+ this.currentCommand = cmd;
+ this.contentLoading = true;
+ this.contentHtml = "";
+
+ // Expand the parent namespace(s) in the sidebar
+ if ( cmd.namespace ) {
+ cmd.namespace.split( " " ).forEach( ( part, i, parts ) => {
+ const key = i === 0 ? part : parts.slice( 0, i + 1 ).join( "/" );
+ if ( !this.expandedNamespaces.includes( key ) ) {
+ this.expandedNamespaces.push( key );
+ }
+ } );
+ }
+
+ // Update URL hash for browser back/forward — flag it so the hashchange
+ // listener knows this change originated here and skips re-routing.
+ this._navigatingProgrammatically = true;
+ window.location.hash = cmd.link.replace( /\.html$/, "" );
+
+ try {
+ const response = await fetch( cmd.link );
+ if ( response.ok ) {
+ const html = await response.text();
+ const parser = new DOMParser();
+ const doc = parser.parseFromString( html, "text/html" );
+ // Use the stable id first; nav.cfm also emits a .container-fluid so
+ // querySelector( '.container-fluid' ) would return the wrong element.
+ const containers = doc.querySelectorAll( ".container-fluid" );
+ const container = doc.getElementById( "command-content" )
+ || ( containers.length > 0 ? containers[ containers.length - 1 ] : null );
+ this.contentHtml = container ? container.innerHTML : doc.body.innerHTML;
+ } else {
+ const safeCommand = this.escapeHtml( cmd.command );
+ this.contentHtml = `
+
+
Not Found
+
Unable to load documentation for ${ safeCommand } .
+
`;
+ }
+ } catch ( error ) {
+ const safeCommand = this.escapeHtml( cmd.command );
+ const safeMessage = this.escapeHtml( error.message );
+ this.contentHtml = `
+
+
Error
+
Failed to load ${ safeCommand } : ${ safeMessage }
+
`;
+ }
+
+ this.contentLoading = false;
+
+ this.$nextTick( () => {
+ this.scrollContentTop();
+ // Re-run syntax highlighting on the newly injected content
+ if ( window.SyntaxHighlighter ) {
+ SyntaxHighlighter.config.stripBrs = true;
+ SyntaxHighlighter.defaults.gutter = false;
+ SyntaxHighlighter.defaults.toolbar = false;
+ SyntaxHighlighter.highlight();
+ }
+ } );
+ },
+
+ // ── Sidebar ──────────────────────────────────────────────────────────
+ toggleNamespace( key ) {
+ const idx = this.expandedNamespaces.indexOf( key );
+ if ( idx > -1 ) {
+ this.expandedNamespaces.splice( idx, 1 );
+ } else {
+ this.expandedNamespaces.push( key );
+ }
+ },
+
+ isExpanded( key ) {
+ return this.expandedNamespaces.includes( key );
+ },
+
+ // ── Search ────────────────────────────────────────────────────────────
+ performSearch() {
+ if ( !this.searchQuery.trim() ) {
+ this.searchResults = [];
+ this.selectedSearchIndex = 0;
+ return;
+ }
+ const q = this.searchQuery.toLowerCase();
+ this.searchResults = this.allCommands
+ .filter( c =>
+ c.command.toLowerCase().includes( q ) ||
+ c.searchList.toLowerCase().includes( q ) ||
+ ( c.hint && c.hint.toLowerCase().includes( q ) )
+ )
+ .slice( 0, 12 );
+ this.selectedSearchIndex = 0;
+ },
+
+ navigateSearch( direction ) {
+ if ( !this.searchResults.length ) return;
+ this.selectedSearchIndex += direction;
+ if ( this.selectedSearchIndex < 0 ) {
+ this.selectedSearchIndex = this.searchResults.length - 1;
+ } else if ( this.selectedSearchIndex >= this.searchResults.length ) {
+ this.selectedSearchIndex = 0;
+ }
+ },
+
+ selectSearchResult() {
+ if ( this.searchResults.length > 0 ) {
+ this.loadCommand( this.searchResults[ this.selectedSearchIndex ] );
+ this.searchQuery = "";
+ this.searchResults = [];
+ }
+ },
+
+ // ── Utilities ────────────────────────────────────────────────────────
+ scrollContentTop() {
+ const main = document.getElementById( "main-content" );
+ if ( main ) main.scrollTop = 0;
+ },
+
+ // Escape a string for safe insertion into HTML to prevent XSS
+ escapeHtml( str ) {
+ if ( typeof str !== "string" ) return "";
+ const div = document.createElement( "div" );
+ div.textContent = str;
+ return div.innerHTML;
+ },
+
+ // ── Computed ─────────────────────────────────────────────────────────
+ get filteredNamespaces() {
+ if ( !this.commandFilter.trim() ) return this.namespaces;
+ const f = this.commandFilter.toLowerCase();
+ return this.namespaces.filter( ns =>
+ ns.name.toLowerCase().includes( f ) ||
+ ns.commands.some( c => c.command.toLowerCase().includes( f ) ) ||
+ ns.children.some( ch =>
+ ch.name.toLowerCase().includes( f ) ||
+ ch.commands.some( c => c.command.toLowerCase().includes( f ) )
+ )
+ );
+ },
+
+ get totalCommandCount() {
+ return this.allCommands.length;
+ },
+
+ // Root namespace object for the current command (first word of namespace)
+ get currentCommandParentNs() {
+ if ( !this.currentCommand?.namespace ) return null;
+ const parentName = this.currentCommand.namespace.split( " " )[ 0 ];
+ return this.namespaces.find( n => n.name === parentName ) || null;
+ },
+
+ // Child namespace object for the current command (second word of namespace, if present)
+ get currentCommandChildNs() {
+ if ( !this.currentCommand?.namespace ) return null;
+ const parts = this.currentCommand.namespace.split( " " );
+ if ( parts.length < 2 ) return null;
+ const parent = this.namespaces.find( n => n.name === parts[ 0 ] );
+ return parent?.children?.find( c => c.name === parts[ 1 ] ) || null;
+ },
+
+ get totalNamespaceCount() {
+ return this.namespaces.length;
+ }
+ };
+}
diff --git a/strategy/CommandBox/resources/templates/class.cfm b/strategy/CommandBox/themes/default/resources/templates/class.cfm
similarity index 66%
rename from strategy/CommandBox/resources/templates/class.cfm
rename to strategy/CommandBox/themes/default/resources/templates/class.cfm
index 90b4380..8ecd66f 100644
--- a/strategy/CommandBox/resources/templates/class.cfm
+++ b/strategy/CommandBox/themes/default/resources/templates/class.cfm
@@ -9,27 +9,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -42,46 +21,52 @@
>
-#arguments.command#
-
-
-
-
-
-
Aliases:
-
-
- #local.alias#
-
-
-
+
+
+
+
+ #arguments.command#
+
+
+
+
+
+ Aliases:
+
+
+ #local.alias#
+
+
+
-
-
+
-
- // All we care about is the "run()" method
- local.qFunctions = buildFunctionMetaData( arguments.metadata );
- local.qFunctions = getMetaSubQuery(local.qFunctions, "UPPER(name)='RUN'");
-
+
+ // All we care about is the "run()" method
+ local.qFunctions = buildFunctionMetaData( arguments.metadata );
+ local.qFunctions = getMetaSubQuery(local.qFunctions, "UPPER(name)='RUN'");
+
-
-
-
-
-
+
+
+
+
+
-
-
-
Parameters:
-
+
+
+
+
+
- Name
- Type
- Required
- Default
- Hint
+ Name
+ Type
+ Required
+ Default
+ Hint
+
+
@@ -107,22 +92,45 @@
+
-
+
+
-
+
-
+
+ Command Usage
+
+ #writeHint( documentation.hint )#
+
+
-
- Command Usage
-
-
#writeHint( documentation.hint )#
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+