Skip to main content

Plugin System Architecture

Scully’s plugin system is a sophisticated architecture that provides extensibility at every stage of the static site generation process. This guide explores how plugins are registered, discovered, configured, and executed.

Core Concepts

Plugin Repository

At the heart of Scully’s plugin system is the plugin repository (pluginRepository.ts), which maintains a registry of all available plugins organized by type:
export const plugins: Plugins = {
  beforeAll: {},
  allDone: {},
  enterprise: {},
  fileHandler: {},
  postProcessByDom: {},
  postProcessByHtml: {},
  render: {},
  routeDiscoveryDone: {},
  routeProcess: {},
  router: {},
  scullySystem: {},
};
Each plugin type is a key in this object, mapping plugin names to their implementation functions.

Plugin Types

Scully defines eleven plugin types, each serving a specific purpose:
export const pluginTypes = [
  'beforeAll',
  'allDone',
  'enterprise',
  'fileHandler',
  'postProcessByDom',
  'postProcessByHtml',
  'render',
  'routeDiscoveryDone',
  'routeProcess',
  'router',
  'scullySystem',
] as const;

Plugin Registration

The registerPlugin Function

Plugins are registered using the registerPlugin function, which validates the plugin and adds it to the appropriate registry:
registerPlugin(
  type: PluginType,
  name: string | symbol,
  plugin: PluginFunction,
  pluginOptions?: ConfigValidator | number | string[],
  options?: RegisterOptions
): void
Parameters:
  • type: The plugin type (e.g., ‘router’, ‘postProcessByHtml’)
  • name: Unique identifier for the plugin
  • plugin: The plugin function implementation
  • pluginOptions: Type-specific configuration (validator, priority, file extensions)
  • options: Additional options like replaceExistingPlugin

Registration Examples

Registering a Router Plugin

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

function myRouterPlugin(route: string, config: any): Promise<HandledRoute[]> {
  // Fetch data and return handled routes
  return Promise.resolve([
    { route: '/products/1' },
    { route: '/products/2' },
    { route: '/products/3' },
  ]);
}

// Validator function to check configuration
const validator = async (config) => {
  const errors = [];
  if (!config.apiUrl) {
    errors.push('apiUrl is required');
  }
  return errors;
};

registerPlugin('router', 'myRouter', myRouterPlugin, validator);

Registering a Post-Processing Plugin

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

function addAnalytics(html: string, route: HandledRoute): Promise<string> {
  const analyticsScript = '<script>/* Analytics code */</script>';
  return Promise.resolve(html.replace('</body>', `${analyticsScript}</body>`));
}

registerPlugin('postProcessByHtml', 'analytics', addAnalytics);

Registering a File Handler Plugin

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

function customFileHandler(html: string, route: HandledRoute): Promise<string> {
  // Process custom file format
  return Promise.resolve(html);
}

// The third parameter specifies file extensions this handler supports
registerPlugin('fileHandler', 'custom', customFileHandler, ['.custom', '.cst']);

Type-Specific Registration

Different plugin types have unique registration requirements:

Router Plugins

  • Must provide a config validator function
  • Validator checks route configuration for errors
plugin[configValidator] = typeof pluginOptions === 'function' 
  ? pluginOptions 
  : () => [] as string[];

File Handler Plugins

  • Must specify supported file extensions
  • Extensions determine which files the plugin processes
plugin[AlternateExtensionsForFilePlugin] = 
  Array.isArray(pluginOptions) ? pluginOptions : [];

Route Process Plugins

  • Can specify execution priority
  • Higher priority plugins run first
plugin[routeProcessPriority] = 
  typeof pluginOptions === 'number' ? pluginOptions : 100;

Before All Plugins

  • Can specify execution priority
  • Lower priority values run first
plugin[priority] = typeof pluginOptions === 'number' ? pluginOptions : 100;

Plugin Discovery

Scully provides utility functions to find and retrieve plugins from the registry:

Finding a Plugin

import { findPlugin } from '@scullyio/scully';

// Find plugin by name (searches all types)
const plugin = findPlugin('myPlugin');

// Find plugin by name and type
const routerPlugin = findPlugin('myRouter', 'router');

Checking Plugin Existence

import { hasPlugin } from '@scullyio/scully';

if (hasPlugin('myPlugin', 'router')) {
  // Plugin exists
}

Fetching Multiple Plugins

import { fetchPlugins } from '@scullyio/scully';

// Get all plugins with a specific name across all types
const plugins = fetchPlugins('contentTransform');

// Get plugins by name and type
const htmlPlugins = fetchPlugins('contentTransform', 'postProcessByHtml');

Plugin Configuration

Setting Global Configuration

Plugins can have global configuration that applies to all usages:
import { setPluginConfig } from '@scullyio/scully';

setPluginConfig('md', { 
  enableSyntaxHighlighting: true,
  theme: 'prism-tomorrow'
});

Setting Route-Specific Configuration

Plugins can have different configurations for different routes:
import { routePluginConfig } from '@scullyio/scully';

routePluginConfig(
  '/blog/:slug',
  'customPlugin',
  { specialOption: true }
);

Getting Plugin Configuration

import { getPluginConfig } from '@scullyio/scully';

const config = getPluginConfig('myPlugin');

Plugin Execution

Plugin Wrapper

All plugins are executed through a wrapper function (pluginWrap.ts) that provides:
  • Error handling with try-catch
  • Performance measurement
  • Route-specific configuration injection
  • Logging and error reporting
export async function wrap(
  type: string,
  name: string | symbol,
  plugin: PluginFunction,
  args: any
): Promise<any> {
  // Performance tracking
  performance.mark('start' + id);
  
  try {
    // Inject route-specific config if available
    if (plugin[routeConfigData] && plugin[routeConfigData][currentRoute]) {
      plugin[configData] = plugin[routeConfigData][currentRoute];
    }
    
    // Execute plugin
    result = await plugin(...args);
  } catch (e) {
    // Error handling
    logError(`Plugin "${name}" threw error`);
    
    if (pluginsError) {
      process.exit(15);
    }
  } finally {
    // Restore global config
    performance.mark('stop' + id);
  }
  
  return result;
}

Execution Flow

  1. Mark Start: Performance tracking begins
  2. Config Injection: Route-specific config is injected if available
  3. Plugin Execution: The plugin function is called with appropriate arguments
  4. Error Handling: Errors are caught and logged
  5. Config Restoration: Global config is restored
  6. Mark Stop: Performance tracking ends
  7. Return Result: The plugin result is returned

Plugin Lifecycle

Plugins execute at specific points in Scully’s build lifecycle:
┌─────────────────────────────────────────────────────────────┐
│ Scully Build Lifecycle                                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Load Configuration                                      │
│     └─> Execute beforeAll plugins                          │
│                                                             │
│  2. Route Discovery                                         │
│     └─> Execute router plugins                             │
│     └─> Execute routeProcess plugins                       │
│     └─> Execute routeDiscoveryDone plugins                 │
│                                                             │
│  3. Route Rendering (for each route)                       │
│     ├─> Launch Puppeteer                                   │
│     ├─> Render Angular application                         │
│     ├─> Execute fileHandler plugins (if content route)     │
│     ├─> Execute postProcessByDom plugins                   │
│     └─> Execute postProcessByHtml plugins                  │
│                                                             │
│  4. Completion                                              │
│     └─> Execute allDone plugins                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Plugin Interfaces

Type Definitions

// Router plugin
type RoutePlugin = {
  (route?: string, config?: any): Promise<HandledRoute[]>;
  [configValidator]?: ConfigValidator | undefined;
};

// Post-process plugins
type postProcessByHtmlPlugin = (
  html?: string,
  route?: HandledRoute
) => Promise<string>;

type postProcessByDomPlugin = (
  dom?: JSDOM,
  route?: HandledRoute
) => Promise<JSDOM>;

// File handler
type FilePlugin = {
  (html: string, route?: HandledRoute): Promise<string>;
  [AlternateExtensionsForFilePlugin]?: string[];
};

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

// Lifecycle plugins
type BeforeAllPlugin = {
  (): Promise<void | undefined | boolean>;
  [priority]?: number;
};

type AllDonePlugin = (routes?: HandledRoute[]) => Promise<void>;

type RouteDiscoveryPlugin = (routes?: HandledRoute[]) => Promise<void>;

Advanced Features

Priority Control

For plugins that support priority (beforeAll, routeProcess):
import { setPluginPriority } from '@scullyio/scully';

// Set priority for a routeProcess plugin
setPluginPriority('myRouteProcessor', 50);
Lower priority values execute first.

Replacing Existing Plugins

By default, registering a plugin with an existing name throws an error. You can override this:
registerPlugin(
  'postProcessByHtml',
  'existingPlugin',
  myNewImplementation,
  undefined,
  { replaceExistingPlugin: true }
);

Direct Plugin Access

The wrapper stores a reference to the original plugin function:
const wrappedPlugin = findPlugin('myPlugin');
const originalPlugin = wrappedPlugin[accessPluginDirectly];

Built-in System Plugins

Scully automatically registers system plugins at startup (systemPlugins.ts):
  • asciidoc: File handler for .adoc files
  • html: File handler for .html files
  • markdown: File handler for .md files
  • contentFolder: Router plugin for content discovery
  • json: Router plugin for JSON data sources
  • ignored: Router plugin for ignored routes
  • seoHrefOptimise: Post-process plugin for SEO
These plugins are loaded automatically and ready to use without explicit registration.

Best Practices

Plugin Naming

  • Use descriptive, kebab-case names: 'my-custom-plugin'
  • Avoid generic names that might conflict
  • Use symbols only when necessary (deprecated)

Error Handling

  • Always return promises (or use async functions)
  • Handle errors gracefully within your plugin
  • Provide meaningful error messages
  • Consider whether errors should be fatal

Performance

  • Keep plugins fast and efficient
  • Avoid unnecessary processing
  • Use caching when appropriate
  • Monitor plugin timing data

Configuration

  • Provide sensible defaults
  • Validate configuration in router plugins
  • Document all configuration options
  • Support both global and route-specific config

Testing

  • Test plugins in isolation
  • Test with various route configurations
  • Test error conditions
  • Verify performance impact

Example: Complete Custom Plugin

Here’s a complete example showing all aspects of plugin creation:
import { registerPlugin, HandledRoute, getPluginConfig } from '@scullyio/scully';
import { JSDOM } from 'jsdom';

// Define configuration interface
interface MyPluginConfig {
  enabled: boolean;
  customOption: string;
}

// Create the plugin function
async function myCustomPlugin(
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> {
  // Get plugin configuration
  const config = getPluginConfig<MyPluginConfig>('myCustomPlugin');
  
  if (!config.enabled) {
    return dom;
  }
  
  // Perform DOM manipulation
  const document = dom.window.document;
  const heading = document.querySelector('h1');
  
  if (heading) {
    heading.textContent += ` - ${config.customOption}`;
  }
  
  return dom;
}

// Register the plugin
registerPlugin('postProcessByDom', 'myCustomPlugin', myCustomPlugin);

// In scully.config.ts:
import { setPluginConfig } from '@scullyio/scully';
import './plugins/myCustomPlugin';

setPluginConfig('myCustomPlugin', {
  enabled: true,
  customOption: 'Modified by plugin'
});

Troubleshooting

Plugin Not Found

If you see “Plugin not found” errors:
  1. Ensure the plugin is imported in your scully.config.ts
  2. Verify the plugin name matches exactly
  3. Check that registerPlugin was called

Configuration Issues

If plugin configuration isn’t working:
  1. Call setPluginConfig after importing the plugin
  2. Ensure configuration object matches expected interface
  3. Check for typos in plugin names

Performance Problems

If plugins are slow:
  1. Review Scully’s performance output
  2. Optimize expensive operations
  3. Consider caching results
  4. Use route-specific configuration to limit processing

Next Steps

Build docs developers (and LLMs) love