Skip to main content
Route process plugins allow you to modify, filter, or enhance the entire list of routes after they’ve been discovered by router plugins but before rendering begins.

Purpose

Route process plugins are responsible for:
  • Filtering routes based on criteria (environment, flags, metadata)
  • Adding computed properties to routes
  • Sorting or reorganizing the route list
  • Generating derived routes (pagination, categories, tags)
  • Validating route data
  • Creating sitemaps or route indexes

Function Signature

type RouteProcess = {
  (routes?: HandledRoute[]): Promise<HandledRoute[]>;
  [routeProcessPriority]?: number;
};

interface HandledRoute {
  /** the _complete_ route */
  route: string;
  /** the type of route */
  type?: string;
  /** route configuration */
  config?: RouteConfig;
  /** template file path for content routes */
  templateFile?: string;
  /** additional data */
  data?: RouteData;
  // ... other properties
}

Lifecycle

  1. Router plugins discover and generate routes
  2. All routes are collected into a single array
  3. Route process plugins run in priority order (lowest to highest)
    • Each plugin receives the full route list
    • Each plugin returns a modified route list
    • The output of one plugin becomes the input of the next
  4. The final route list is stored in scully.routes.json
  5. Routes are rendered using the processed list

Priority System

Route process plugins execute in order based on their priority (lower numbers run first):
const myPlugin = async (routes: HandledRoute[]): Promise<HandledRoute[]> => {
  // Plugin logic
  return routes;
};

// Register with priority 50 (runs before default priority of 100)
registerPlugin('routeProcess', 'myPlugin', myPlugin, 50);
Default priority is 100 if not specified.

When to Use

Create a route process plugin when you need to:
  • Filter routes based on environment or feature flags
  • Add pagination to a list of routes
  • Generate category or tag pages from content metadata
  • Sort routes by date, title, or custom criteria
  • Validate that all routes have required metadata
  • Add computed properties (reading time, related posts)
  • Create route hierarchies or breadcrumbs
  • Generate sitemap data

Implementation Examples

Basic Route Process Plugin

import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

export const myRouteProcess = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  // Modify the routes array
  const modifiedRoutes = routes.map(route => ({
    ...route,
    data: {
      ...route.data,
      processedAt: new Date().toISOString(),
    },
  }));
  
  return modifiedRoutes;
};

// Register with default priority (100)
registerPlugin('routeProcess', 'myRouteProcess', myRouteProcess);

Filter Unpublished Routes

export const filterUnpublished = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  const isProduction = process.env.NODE_ENV === 'production';
  
  if (!isProduction) {
    // In development, show all routes
    return routes;
  }
  
  // In production, filter out unpublished routes
  return routes.filter(route => {
    if (!route.data) return true;
    
    // Check published flag
    if (route.data.published === false) return false;
    
    // Check publish date
    if (route.data.publishDate) {
      const publishDate = new Date(route.data.publishDate);
      const now = new Date();
      return publishDate <= now;
    }
    
    return true;
  });
};

// Run early (priority 50) to reduce routes before other processing
registerPlugin('routeProcess', 'filterUnpublished', filterUnpublished, 50);

Sort Blog Posts by Date

export const sortByDate = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  // Separate blog posts from other routes
  const blogPosts = routes.filter(r => r.route.startsWith('/blog/'));
  const otherRoutes = routes.filter(r => !r.route.startsWith('/blog/'));
  
  // Sort blog posts by date (newest first)
  blogPosts.sort((a, b) => {
    const dateA = a.data?.date ? new Date(a.data.date).getTime() : 0;
    const dateB = b.data?.date ? new Date(b.data.date).getTime() : 0;
    return dateB - dateA;
  });
  
  // Combine and return
  return [...otherRoutes, ...blogPosts];
};

registerPlugin('routeProcess', 'sortByDate', sortByDate);

Add Reading Time

import { readFileSync } from 'fs';

export const addReadingTime = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  const WORDS_PER_MINUTE = 200;
  
  return routes.map(route => {
    // Only process content routes
    if (!route.templateFile) return route;
    
    try {
      // Read file content
      const content = readFileSync(route.templateFile, 'utf-8');
      
      // Count words (simple implementation)
      const wordCount = content.split(/\s+/).length;
      const readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
      
      // Add to route data
      return {
        ...route,
        data: {
          ...route.data,
          wordCount,
          readingTime,
        },
      };
    } catch (error) {
      console.error(`Error reading ${route.templateFile}:`, error);
      return route;
    }
  });
};

registerPlugin('routeProcess', 'addReadingTime', addReadingTime);

Generate Pagination Routes

export const generatePagination = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  const POSTS_PER_PAGE = 10;
  
  // Get blog posts
  const blogPosts = routes
    .filter(r => r.route.startsWith('/blog/') && r.route !== '/blog')
    .sort((a, b) => {
      const dateA = a.data?.date ? new Date(a.data.date).getTime() : 0;
      const dateB = b.data?.date ? new Date(b.data.date).getTime() : 0;
      return dateB - dateA;
    });
  
  // Calculate pagination
  const totalPages = Math.ceil(blogPosts.length / POSTS_PER_PAGE);
  const paginationRoutes: HandledRoute[] = [];
  
  for (let page = 1; page <= totalPages; page++) {
    const start = (page - 1) * POSTS_PER_PAGE;
    const end = start + POSTS_PER_PAGE;
    const pagePosts = blogPosts.slice(start, end);
    
    paginationRoutes.push({
      route: page === 1 ? '/blog' : `/blog/page/${page}`,
      type: 'blogList',
      data: {
        posts: pagePosts.map(p => ({
          route: p.route,
          title: p.data?.title,
          date: p.data?.date,
          excerpt: p.data?.excerpt,
        })),
        currentPage: page,
        totalPages,
        hasNextPage: page < totalPages,
        hasPrevPage: page > 1,
      },
    });
  }
  
  // Combine with original routes
  return [...routes, ...paginationRoutes];
};

registerPlugin('routeProcess', 'generatePagination', generatePagination);

Generate Tag Pages

export const generateTagPages = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  // Collect all tags from blog posts
  const tagMap = new Map<string, HandledRoute[]>();
  
  routes
    .filter(r => r.route.startsWith('/blog/') && r.data?.tags)
    .forEach(route => {
      const tags = Array.isArray(route.data.tags) 
        ? route.data.tags 
        : [route.data.tags];
      
      tags.forEach(tag => {
        if (!tagMap.has(tag)) {
          tagMap.set(tag, []);
        }
        tagMap.get(tag)!.push(route);
      });
    });
  
  // Generate tag pages
  const tagRoutes: HandledRoute[] = Array.from(tagMap.entries()).map(
    ([tag, posts]) => ({
      route: `/blog/tag/${slugify(tag)}`,
      type: 'tagPage',
      data: {
        tag,
        posts: posts.map(p => ({
          route: p.route,
          title: p.data?.title,
          date: p.data?.date,
        })),
      },
    })
  );
  
  // Generate tag index page
  const tagIndexRoute: HandledRoute = {
    route: '/blog/tags',
    type: 'tagIndex',
    data: {
      tags: Array.from(tagMap.entries()).map(([tag, posts]) => ({
        tag,
        count: posts.length,
        route: `/blog/tag/${slugify(tag)}`,
      })),
    },
  };
  
  return [...routes, ...tagRoutes, tagIndexRoute];
};

function slugify(str: string): string {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

registerPlugin('routeProcess', 'generateTagPages', generateTagPages);

Validate Required Fields

export const validateRoutes = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  routes.forEach(route => {
    // Only validate blog posts
    if (!route.route.startsWith('/blog/')) return;
    
    const errors: string[] = [];
    
    // Check required fields
    if (!route.data?.title) {
      errors.push('Missing required field: title');
    }
    
    if (!route.data?.date) {
      errors.push('Missing required field: date');
    }
    
    if (!route.data?.author) {
      errors.push('Missing required field: author');
    }
    
    // Log errors
    if (errors.length > 0) {
      console.warn(`\nValidation errors for ${route.route}:`);
      errors.forEach(err => console.warn(`  - ${err}`));
    }
  });
  
  // Return routes unchanged (just validation)
  return routes;
};

// Run late (priority 200) after other processing
registerPlugin('routeProcess', 'validateRoutes', validateRoutes, 200);

Changing Plugin Priority

You can change a plugin’s priority after registration:
import { setPluginPriority } from '@scullyio/scully';

// Change priority of existing plugin
setPluginPriority('myPlugin', 50);

How Routes are Processed

// Scully's internal processing (simplified)
async function processRoutes(routes: HandledRoute[]): Promise<HandledRoute[]> {
  let result = routes;
  
  // Get all route process plugins
  const processors = Object.values(plugins.routeProcess)
    // Sort by priority (low to high)
    .sort((a, b) => {
      const priorityA = a[routeProcessPriority] || 100;
      const priorityB = b[routeProcessPriority] || 100;
      return priorityA - priorityB;
    });
  
  // Run each processor in sequence
  for (const processor of processors) {
    try {
      result = await processor(result);
    } catch (error) {
      console.error('Error in route process plugin:', error);
      // Continue with unchanged routes
    }
  }
  
  return result;
}

Configuration

Route process plugins are registered globally and run on all builds:
import { ScullyConfig } from '@scullyio/scully';
import './my-route-process-plugin';

export const config: ScullyConfig = {
  // Route process plugins don't need configuration here
  // They run automatically after being registered
  
  routes: {
    // ... route config
  },
};
If you need configuration, use plugin config:
import { getConfig, setConfig } from '@scullyio/scully';

interface MyPluginConfig {
  postsPerPage: number;
}

const myPlugin = async (routes: HandledRoute[]): Promise<HandledRoute[]> => {
  const config = getConfig<MyPluginConfig>(myPlugin);
  // Use config.postsPerPage
  return routes;
};

setConfig(myPlugin, { postsPerPage: 10 });

registerPlugin('routeProcess', 'myPlugin', myPlugin);

Best Practices

  1. Always return an array: Even if you don’t modify routes, return the input array
  2. Don’t mutate routes directly: Create new objects with spread operators
  3. Handle missing data gracefully: Check for route.data existence
  4. Use appropriate priority: Run filters early, enhancements late
  5. Log important changes: Inform users when routes are filtered or modified
  6. Performance matters: Process all routes efficiently
  7. Avoid async I/O in loops: Use Promise.all() for parallel operations
  8. Document side effects: Clearly document what your plugin does
  9. Test with large route sets: Ensure scalability
  10. Be idempotent: Running multiple times should produce same result

Priority Guidelines

  • 0-49: Critical early processing (filtering, validation)
  • 50-99: Data enrichment (reading files, computing values)
  • 100-149: Default processing (sorting, organizing)
  • 150-199: Route generation (pagination, tags, categories)
  • 200+: Final validation, logging, reporting

Error Handling

export const safeRouteProcess = async (
  routes: HandledRoute[]
): Promise<HandledRoute[]> => {
  try {
    // Your processing logic
    return routes.map(route => ({
      ...route,
      data: { ...route.data, processed: true },
    }));
  } catch (error) {
    console.error('Error in route processing:', error);
    // Return original routes on error
    return routes;
  }
};

Accessing Processed Routes

The final processed route list is stored in scully.routes.json:
[
  {
    "route": "/blog/my-post",
    "title": "My Post",
    "date": "2024-01-15",
    "readingTime": 5,
    "tags": ["angular", "scully"]
  }
]
You can read this file in your application:
import { ScullyRoutesService } from '@scullyio/ng-lib';

export class BlogComponent {
  routes$ = this.scully.available$;
  
  constructor(private scully: ScullyRoutesService) {}
}

Built-in Route Process Plugins

Scully doesn’t include built-in route process plugins by default, but the ecosystem has several:
  • scully-plugin-sitemap: Generates sitemap.xml from routes
  • scully-plugin-rss: Generates RSS feed from routes
  • Custom plugins for specific use cases

Build docs developers (and LLMs) love