@@ -51,6 +51,8 @@ export interface ParsedCommand {
5151 middlewares : string [ ] ;
5252 /** Absolute path to this command */
5353 fullPath : string ;
54+ /** Category the command belongs to, if any */
55+ category : string | null ;
5456}
5557
5658/**
@@ -66,6 +68,8 @@ export interface ParsedMiddleware {
6668 path : string ;
6769 /** Absolute path to the middleware file */
6870 fullPath : string ;
71+ /** Category the middleware belongs to, if any */
72+ category : string | null ;
6973}
7074
7175/**
@@ -244,68 +248,81 @@ export class CommandsRouter {
244248 * @returns Promise resolving to the complete commands tree
245249 */
246250 public async scan ( ) : Promise < CommandsTree > {
251+ this . clear ( ) ;
247252 const files = await this . scanDirectory ( this . entrypoint , [ ] ) ;
248253
249- for ( const file of files ) {
250- if ( this . execMatcher ( this . matchers . command , file ) ) {
251- const location = this . resolveRelativePath ( file ) ;
252- const parts = location . split ( path . sep ) ;
253-
254- const parentSegments : string [ ] = [ ] ;
255-
256- parts . forEach ( ( part , index , arr ) => {
257- const isLast = index === arr . length - 1 ;
258-
259- // ignore last because it's definitely a command source file
260- if ( isLast ) return ;
261-
262- // we ignore groups
263- if ( ! / \( .+ \) / . test ( part ) ) {
264- parentSegments . push ( part . trim ( ) ) ;
265- }
266- } ) ;
267-
268- const parent = parentSegments . join ( ' ' ) ;
269- const name = parts [ parts . length - 2 ] ;
270-
271- const command : ParsedCommand = {
272- name,
273- middlewares : [ ] ,
274- parent : parent || null ,
275- path : location ,
276- fullPath : file ,
277- parentSegments,
278- } ;
254+ // First pass: collect all files
255+ const commandFiles = files . filter ( ( file ) => {
256+ const basename = path . basename ( file ) ;
257+ return ! this . isIgnoredFile ( basename ) && this . isCommandFile ( file ) ;
258+ } ) ;
279259
280- this . commands . set ( name , command ) ;
281- }
260+ // Second pass: process middleware
261+ const middlewareFiles = files . filter ( ( file ) =>
262+ this . execMatcher ( this . matchers . middleware , file ) ,
263+ ) ;
264+
265+ // Process commands
266+ for ( const file of commandFiles ) {
267+ const parsedPath = this . parseCommandPath ( file ) ;
268+ const location = this . resolveRelativePath ( file ) ;
269+
270+ const command : ParsedCommand = {
271+ name : parsedPath . name ,
272+ path : location ,
273+ fullPath : file ,
274+ parent : parsedPath . parent ,
275+ parentSegments : parsedPath . parentSegments ,
276+ category : parsedPath . category ,
277+ middlewares : [ ] ,
278+ } ;
282279
283- if ( this . execMatcher ( this . matchers . middleware , file ) ) {
284- const location = this . resolveRelativePath ( file ) ;
285- const name = location . replace ( / \. ( m | c ) ? ( j | t ) s x ? $ / , '' ) ;
286- const middlewareDir = path . dirname ( location ) ;
280+ this . commands . set ( parsedPath . name , command ) ;
281+ }
287282
288- const command = Array . from ( this . commands . values ( ) ) . filter ( ( command ) => {
289- const commandDir = path . dirname ( command . path ) ;
290- return (
291- commandDir === middlewareDir || commandDir . startsWith ( middlewareDir )
292- ) ;
293- } ) ;
283+ // Process middleware
284+ for ( const file of middlewareFiles ) {
285+ const location = this . resolveRelativePath ( file ) ;
286+ const dirname = path . dirname ( location ) ;
287+ const id = crypto . randomUUID ( ) ;
288+ const parts = location . split ( path . sep ) . filter ( ( p ) => p ) ;
289+ const categories = this . parseCategories ( parts ) ;
290+
291+ const middleware : ParsedMiddleware = {
292+ id,
293+ name : dirname ,
294+ path : location ,
295+ fullPath : file ,
296+ category : categories . length ? categories . join ( '/' ) : null ,
297+ } ;
294298
295- const id = crypto . randomUUID ( ) ;
299+ this . middlewares . set ( id , middleware ) ;
296300
297- const middleware : ParsedMiddleware = {
298- id,
299- name,
300- path : location ,
301- fullPath : file ,
302- } ;
301+ // Apply middleware based on location
302+ const isGlobalMiddleware = path . parse ( file ) . name === 'middleware' ;
303+ const commands = Array . from ( this . commands . values ( ) ) ;
303304
304- this . middlewares . set ( id , middleware ) ;
305+ for ( const command of commands ) {
306+ const commandDir = path . dirname ( command . path ) ;
305307
306- command . forEach ( ( cmd ) => {
307- cmd . middlewares . push ( id ) ;
308- } ) ;
308+ if ( isGlobalMiddleware ) {
309+ // Global middleware applies if command is in same dir or nested
310+ if (
311+ commandDir === dirname ||
312+ commandDir . startsWith ( dirname + path . sep )
313+ ) {
314+ command . middlewares . push ( id ) ;
315+ }
316+ } else {
317+ // Specific middleware only applies to exact command match
318+ const commandName = command . name ;
319+ const middlewareName = path
320+ . basename ( file )
321+ . replace ( / \. m i d d l e w a r e \. ( m | c ) ? ( j | t ) s x ? $ / , '' ) ;
322+ if ( commandName === middlewareName && commandDir === dirname ) {
323+ command . middlewares . push ( id ) ;
324+ }
325+ }
309326 }
310327 }
311328
@@ -357,4 +374,45 @@ export class CommandsRouter {
357374
358375 return entries ;
359376 }
377+
378+ private isIgnoredFile ( filename : string ) : boolean {
379+ return filename . startsWith ( '_' ) ;
380+ }
381+
382+ private isCommandFile ( path : string ) : boolean {
383+ if ( this . execMatcher ( this . matchers . middleware , path ) ) return false ;
384+ return (
385+ / i n d e x \. ( m | c ) ? ( j | t ) s x ? $ / . test ( path ) || / \. ( m | c ) ? ( j | t ) s x ? $ / . test ( path )
386+ ) ;
387+ }
388+
389+ private parseCategories ( parts : string [ ] ) : string [ ] {
390+ return parts
391+ . filter ( ( part ) => part . startsWith ( '(' ) && part . endsWith ( ')' ) )
392+ . map ( ( part ) => part . slice ( 1 , - 1 ) ) ;
393+ }
394+
395+ private parseCommandPath ( filepath : string ) : {
396+ name : string ;
397+ category : string | null ;
398+ parent : string | null ;
399+ parentSegments : string [ ] ;
400+ } {
401+ const location = this . resolveRelativePath ( filepath ) ;
402+ const parts = location . split ( path . sep ) . filter ( ( p ) => p ) ;
403+ const categories = this . parseCategories ( parts ) ;
404+ const segments : string [ ] = parts . filter (
405+ ( part ) => ! ( part . startsWith ( '(' ) && part . endsWith ( ')' ) ) ,
406+ ) ;
407+
408+ let name = segments . pop ( ) || '' ;
409+ name = name . replace ( / \. ( m | c ) ? ( j | t ) s x ? $ / , '' ) . replace ( / ^ i n d e x $ / , '' ) ;
410+
411+ return {
412+ name,
413+ category : categories . length ? categories . join ( '/' ) : null ,
414+ parent : segments . length ? segments . join ( ' ' ) : null ,
415+ parentSegments : segments ,
416+ } ;
417+ }
360418}
0 commit comments