Building plugins

Aurelia makes it easy to create your plugins. Learn how to create individual plugins, register them, and work with tasks to run code during certain parts of the lifecycle process.

Aurelia plugins allow you to encapsulate functionality that can be reused across multiple applications. They can include custom elements, value converters, binding behaviors, and other resources. The goal is to create packaged, easily shared, ready-to-use functionalities that integrate seamlessly with Aurelia applications.

Understanding Plugin Architecture

At its core, an Aurelia plugin is an object with a register method that configures dependencies and sets up your functionality for use in an Aurelia application. This follows the dependency injection pattern that powers the entire Aurelia framework.

Minimal Plugin Example

Basic Plugin Structure

// my-simple-plugin.ts
import { IContainer } from '@aurelia/kernel';

export const MySimplePlugin = {
  register(container: IContainer): void {
    // Register your plugin resources here
    console.log('Plugin registered!');
  }
};

Registering the Plugin

// main.ts
import Aurelia from 'aurelia';
import { MySimplePlugin } from './my-simple-plugin';

Aurelia
  .register(MySimplePlugin)
  .app(MyApp)
  .start();

Adding Components and Resources

Plugins typically provide custom elements, attributes, value converters, or other resources:

// hello-world.ts
import { customElement } from '@aurelia/runtime-html';

@customElement({
  name: 'hello-world',
  template: '<div>Hello, ${name}!</div>'
})
export class HelloWorld {
  name = 'World';
}
// my-component-plugin.ts
import { IContainer } from '@aurelia/kernel';
import { HelloWorld } from './hello-world';

export const MyComponentPlugin = {
  register(container: IContainer): void {
    container.register(HelloWorld);
  }
};

Creating Configurable Plugins

Modern Configuration Pattern with .customize()

The modern approach uses a .customize() method that follows the same pattern as Aurelia's built-in plugins:

// my-configurable-plugin.ts
import { DI, IContainer, Registration } from '@aurelia/kernel';

export interface MyPluginOptions {
  greeting?: string;
  debug?: boolean;
  theme?: 'light' | 'dark';
}

const defaultOptions: MyPluginOptions = {
  greeting: 'Hello',
  debug: false,
  theme: 'light'
};

export const IMyPluginOptions = DI.createInterface<MyPluginOptions>('IMyPluginOptions');

function createConfiguration(optionsProvider: (options: MyPluginOptions) => void) {
  return {
    register(container: IContainer): void {
      const options = { ...defaultOptions };
      optionsProvider(options);

      container.register(
        Registration.instance(IMyPluginOptions, options),
        // Register other plugin resources
        HelloWorld
      );
    },
    customize(cb: (options: MyPluginOptions) => void) {
      return createConfiguration(cb);
    }
  };
}

export const MyConfigurablePlugin = createConfiguration(() => {
  // Default configuration - no changes needed
});

Using the Configurable Plugin

// main.ts
import Aurelia from 'aurelia';
import { MyConfigurablePlugin } from './my-configurable-plugin';

Aurelia
  .register(
    MyConfigurablePlugin.customize(options => {
      options.greeting = 'Bonjour';
      options.debug = true;
      options.theme = 'dark';
    })
  )
  .app(MyApp)
  .start();

Consuming Configuration in Components

// greeting.ts
import { customElement } from '@aurelia/runtime-html';
import { resolve } from 'aurelia';
import { IMyPluginOptions } from './my-configurable-plugin';

@customElement({
  name: 'greeting',
  template: '<div class="${theme}">${options.greeting}, ${name}!</div>'
})
export class Greeting {
  name = 'World';

  private options = resolve(IMyPluginOptions);

  get theme() {
    return `theme-${this.options.theme}`;
  }
}

Working with App Tasks (Lifecycle Hooks)

App tasks allow you to run code at specific points during the application lifecycle. This is useful for initialization, cleanup, or integration with external libraries.

Available Lifecycle Phases

import { AppTask } from '@aurelia/runtime-html';
import { IContainer } from '@aurelia/kernel';

export const MyLifecyclePlugin = {
  register(container: IContainer): void {
    container.register(
      // Before DI creates the root component
      AppTask.creating(() => {
        console.log('App is being created');
      }),

      // After root component instantiation, before template compilation
      AppTask.hydrating(() => {
        console.log('App is hydrating');
      }),

      // After self-hydration, before child element hydration
      AppTask.hydrated(() => {
        console.log('App hydration completed');
      }),

      // Before root component activation (bindings getting bound)
      AppTask.activating(() => {
        console.log('App is activating');
      }),

      // After root component activation (app is running)
      AppTask.activated(() => {
        console.log('App is activated and running');
      }),

      // Before root component deactivation
      AppTask.deactivating(() => {
        console.log('App is deactivating');
      }),

      // After root component deactivation
      AppTask.deactivated(() => {
        console.log('App has been deactivated');
      })
    );
  }
};

Async App Tasks with DI

App tasks can be asynchronous and can inject dependencies:

import { AppTask } from '@aurelia/runtime-html';
import { IContainer, Registration } from '@aurelia/kernel';
import { ILogger } from '@aurelia/kernel';

export const DataLoadingPlugin = {
  register(container: IContainer): void {
    container.register(
      AppTask.hydrating(IContainer, async (container) => {
        const logger = container.get(ILogger);
        logger.info('Loading initial data...');

        // Conditionally register services based on environment
        if (process.env.NODE_ENV === 'development') {
          const { MockDataService } = await import('./mock-data-service');
          Registration.singleton(IDataService, MockDataService).register(container);
        } else {
          const { RealDataService } = await import('./real-data-service');
          Registration.singleton(IDataService, RealDataService).register(container);
        }

        logger.info('Data services registered successfully');
      })
    );
  }
};

Real-World Plugin Examples

Router-like Plugin Structure

Here's how a plugin similar to Aurelia's router might be structured:

// my-router-plugin.ts
import { IContainer, Registration } from '@aurelia/kernel';
import { AppTask } from '@aurelia/runtime-html';

export interface IRouterOptions {
  basePath?: string;
  enableLogging?: boolean;
}

export const IRouterOptions = DI.createInterface<IRouterOptions>('IRouterOptions');

const defaultOptions: IRouterOptions = {
  basePath: '/',
  enableLogging: false
};

function configure(container: IContainer, options: IRouterOptions = defaultOptions) {
  const finalOptions = { ...defaultOptions, ...options };

  return container.register(
    Registration.instance(IRouterOptions, finalOptions),
    // Register router components
    RouterViewport,
    RouterLink,
    // Lifecycle tasks
    AppTask.activating(IRouter, router => router.start()),
    AppTask.deactivated(IRouter, router => router.stop())
  );
}

export const MyRouterPlugin = {
  register(container: IContainer): IContainer {
    return configure(container);
  },
  customize(options: IRouterOptions) {
    return {
      register(container: IContainer): IContainer {
        return configure(container, options);
      }
    };
  }
};

Validation-like Plugin Structure

Here's how a validation plugin might be structured:

// validation-plugin.ts
import { IContainer, Registration } from '@aurelia/kernel';
import { DI } from '@aurelia/kernel';

export interface ValidationOptions {
  defaultTrigger?: 'blur' | 'change' | 'manual';
  showErrorsOnInit?: boolean;
  errorTemplate?: string;
}

export const IValidationOptions = DI.createInterface<ValidationOptions>('IValidationOptions');

const defaultOptions: ValidationOptions = {
  defaultTrigger: 'blur',
  showErrorsOnInit: false,
  errorTemplate: '<div class="error">${error}</div>'
};

function createValidationConfiguration(optionsProvider: (options: ValidationOptions) => void) {
  return {
    register(container: IContainer): void {
      const options = { ...defaultOptions };
      optionsProvider(options);

      container.register(
        Registration.instance(IValidationOptions, options),
        ValidateBindingBehavior,
        ValidationErrorsCustomAttribute,
        ValidationController
      );
    },
    customize(cb: (options: ValidationOptions) => void) {
      return createValidationConfiguration(cb);
    }
  };
}

export const ValidationPlugin = createValidationConfiguration(() => {
  // Default configuration
});

Template and Style Handling in Plugins

When building plugins with custom elements, you need to decide how to handle templates and styles. Aurelia provides multiple approaches, each suited for different scenarios.

Convention-Based Approach

The convention-based approach relies on file naming and automatic bundler processing. This is ideal for application development but requires specific bundler configuration.

File Structure

my-button/
├── my-button.ts          # Component class
├── my-button.html        # Template (auto-detected)
└── my-button.css         # Styles (auto-imported)

Component Definition

// my-button.ts
import { customElement, bindable } from '@aurelia/runtime-html';

@customElement('my-button') // Name can be inferred from class name
export class MyButton {
  @bindable variant: 'primary' | 'secondary' = 'primary';
  @bindable disabled: boolean = false;
}

Template File

<!-- my-button.html -->
<template>
  <button
    class="btn btn-${variant}"
    disabled.bind="disabled"
    click.trigger="handleClick()">
    <slot></slot>
  </button>
</template>

Styles File

/* my-button.css */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  border: none;
  cursor: pointer;
}

.btn-primary {
  background-color: #007bff;
  color: white;
}

.btn-secondary {
  background-color: #6c757d;
  color: white;
}

Explicit Import Approach

The explicit import approach gives you full control over dependencies and is ideal for UI libraries and distribution packages.

import { customElement, bindable, shadowCSS } from '@aurelia/runtime-html';
import template from './my-button.html';
import styles from './my-button.css';
import sharedStyles from '../shared/variables.css';

@customElement({
  name: 'my-button',
  template,
  dependencies: [shadowCSS(sharedStyles, styles)],
  shadowOptions: { mode: 'open' }
})
export class MyButton {
  @bindable variant: 'primary' | 'secondary' = 'primary';
  @bindable disabled: boolean = false;

  handleClick() {
    if (!this.disabled) {
      // Button click logic
    }
  }
}

Styling Strategies

Light DOM (Global Styles)

Regular CSS injection into the document head:

import './my-button.css'; // Injected globally

@customElement({
  name: 'my-button',
  template: '...'
})
export class MyButton {}

Shadow DOM with CSS

Encapsulated styles using Shadow DOM:

import { shadowCSS } from '@aurelia/runtime-html';
import styles from './my-button.css';

@customElement({
  name: 'my-button',
  template: '...',
  dependencies: [shadowCSS(styles)],
  shadowOptions: { mode: 'open' }
})
export class MyButton {}

CSS Modules

Scoped class names for style isolation:

import { cssModules } from '@aurelia/runtime-html';
import styles from './my-button.module.css';

@customElement({
  name: 'my-button',
  template: '<button class="button"><slot></slot></button>',
  dependencies: [cssModules(styles)]
})
export class MyButton {}

Multiple Template Support

For components with different template variants:

import { customElement } from '@aurelia/runtime-html';
import defaultTemplate from './my-button.html';
import compactTemplate from './my-button-compact.html';

export interface MyButtonOptions {
  variant?: 'default' | 'compact';
}

export const MyButtonPlugin = {
  configure(options: MyButtonOptions = {}) {
    const template = options.variant === 'compact' ? compactTemplate : defaultTemplate;

    return {
      register(container: IContainer): void {
        const ButtonElement = customElement({
          name: 'my-button',
          template
        })(MyButton);

        container.register(ButtonElement);
      }
    };
  }
};

Inline Templates and Styles

For simple components, you can define templates and styles inline:

import { customElement, bindable } from '@aurelia/runtime-html';

@customElement({
  name: 'simple-badge',
  template: `
    <span class="badge badge-\${variant}">
      <slot></slot>
    </span>
  `,
  dependencies: [],
  // Inline styles for Shadow DOM
  shadowOptions: { mode: 'open' }
})
export class SimpleBadge {
  @bindable variant: 'info' | 'success' | 'warning' | 'error' = 'info';
}

Or with CSS-in-JS approach:

import { customElement, shadowCSS } from '@aurelia/runtime-html';

const styles = `
  .badge {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    border-radius: 0.25rem;
    font-size: 0.875rem;
  }
  .badge-info { background-color: #17a2b8; color: white; }
  .badge-success { background-color: #28a745; color: white; }
`;

@customElement({
  name: 'simple-badge',
  template: '<span class="badge badge-${variant}"><slot></slot></span>',
  dependencies: [shadowCSS(styles)],
  shadowOptions: { mode: 'open' }
})
export class SimpleBadge {
  @bindable variant: 'info' | 'success' = 'info';
}

When to Use Each Approach

Use Convention-Based When:

  • Building application-specific plugins

  • Working in a controlled bundler environment

  • Want minimal boilerplate code

  • Following standard Aurelia project structure

Use Explicit Imports When:

  • Building UI libraries for distribution

  • Need precise control over dependencies

  • Supporting multiple bundler configurations

  • Want explicit dependency management

  • Building plugins for npm distribution

TypeScript Support

For explicit imports, provide proper TypeScript declarations:

// types/assets.d.ts
declare module '*.html' {
  const template: string;
  export default template;
}

declare module '*.css' {
  const styles: string;
  export default styles;
}

declare module '*.module.css' {
  const styles: Record<string, string>;
  export default styles;
}

Plugin Template Recommendations

For UI library plugins, structure your components like this:

// src/components/button/au-button.ts
import { customElement, bindable, shadowCSS } from '@aurelia/runtime-html';
import template from './au-button.html';
import styles from './au-button.css';

@customElement({
  name: 'au-button',
  template,
  dependencies: [shadowCSS(styles)],
  shadowOptions: { mode: 'open' }
})
export class AuButton {
  @bindable variant: 'primary' | 'secondary' | 'success' | 'danger' = 'primary';
  @bindable size: 'sm' | 'md' | 'lg' = 'md';
  @bindable disabled: boolean = false;
  @bindable loading: boolean = false;
}

// src/index.ts - Plugin entry point
import { IContainer } from '@aurelia/kernel';
import { AuButton } from './components/button/au-button';
import { AuCard } from './components/card/au-card';

export const UILibraryPlugin = {
  register(container: IContainer): void {
    container.register(
      AuButton,
      AuCard
      // ... other components
    );
  }
};

// Export individual components for selective registration
export { AuButton, AuCard };

This approach provides maximum flexibility for consumers while maintaining clean plugin architecture.

Advanced Plugin Patterns

Conditional Resource Registration

export const ConditionalPlugin = {
  register(container: IContainer): void {
    const isProduction = process.env.NODE_ENV === 'production';

    // Always register core resources
    container.register(CoreService, CoreComponent);

    // Conditionally register development-only resources
    if (!isProduction) {
      container.register(DebugPanel, DevTools);
    }

    // Conditionally register based on feature flags
    const features = container.get(IFeatureFlags);
    if (features.isEnabled('new-ui')) {
      container.register(NewUIComponents);
    }
  }
};

Plugin with Dynamic Imports

export const LazyPlugin = {
  register(container: IContainer): void {
    container.register(
      AppTask.hydrating(IContainer, async (container) => {
        // Load heavy dependencies only when needed
        const config = container.get(IAppConfig);

        if (config.enableCharts) {
          const { ChartComponents } = await import('./chart-components');
          container.register(...ChartComponents);
        }

        if (config.enableMaps) {
          const { MapComponents } = await import('./map-components');
          container.register(...MapComponents);
        }
      })
    );
  }
};

Plugin Composition

// Compose multiple smaller plugins into a larger one
export const UILibraryPlugin = {
  register(container: IContainer): void {
    container.register(
      ButtonPlugin,
      ModalPlugin,
      FormPlugin,
      NavigationPlugin
    );
  }
};

Best Practices

Naming Conventions

  • Plugin Names: Use descriptive names ending with "Plugin" or "Configuration"

  • Interfaces: Prefix with "I" and use DI.createInterface()

  • Resources: Prefix with your plugin name to avoid collisions

// Good
export const ChartPlugin = { /* ... */ };
export const IChartOptions = DI.createInterface<ChartOptions>('IChartOptions');
export class ChartBarElement { /* ... */ }
export class ChartLineElement { /* ... */ }

// Avoid
export const Plugin = { /* ... */ };
export class BarElement { /* ... */ } // Too generic

Error Handling

export const RobustPlugin = {
  register(container: IContainer): void {
    try {
      // Register core functionality
      container.register(CoreService);

      // Optional enhancements
      try {
        const optionalService = container.get(IOptionalDependency);
        container.register(EnhancedFeature);
      } catch {
        // Gracefully degrade if optional dependency is missing
        container.register(BasicFeature);
      }
    } catch (error) {
      console.error('Failed to register plugin:', error);
      throw new Error('Plugin registration failed. Please check your configuration.');
    }
  }
};

TypeScript Integration

// Export types for consumer convenience
export type { MyPluginOptions, IMyPluginService };

// Provide type-safe configuration
export interface IMyPluginBuilder {
  withTheme(theme: 'light' | 'dark'): IMyPluginBuilder;
  withLocale(locale: string): IMyPluginBuilder;
  build(): IRegistry;
}

Testing Plugins

Unit Testing Plugin Registration

// plugin.spec.ts
import { DI } from '@aurelia/kernel';
import { TestContext } from '@aurelia/testing';
import { MyPlugin, IMyPluginOptions } from './my-plugin';

describe('MyPlugin', () => {
  let container: IContainer;

  beforeEach(() => {
    const ctx = TestContext.create();
    container = ctx.container;
  });

  it('registers with default options', () => {
    container.register(MyPlugin);

    const options = container.get(IMyPluginOptions);
    expect(options.greeting).toBe('Hello');
  });

  it('registers with custom options', () => {
    container.register(
      MyPlugin.customize(options => {
        options.greeting = 'Bonjour';
      })
    );

    const options = container.get(IMyPluginOptions);
    expect(options.greeting).toBe('Bonjour');
  });
});

Packaging and Distribution

Package Structure

my-aurelia-plugin/
├── src/
│   ├── index.ts          # Main plugin export
│   ├── components/       # Custom elements
│   ├── attributes/       # Custom attributes
│   ├── services/         # Injectable services
│   └── interfaces.ts     # Type definitions
├── dist/                 # Compiled output
├── package.json
├── tsconfig.json
└── README.md

Package.json Configuration

{
  "name": "@my-org/aurelia-plugin",
  "version": "1.0.0",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/types/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "types": "./dist/types/index.d.ts"
    }
  },
  "peerDependencies": {
    "aurelia": "^2.0.0"
  },
  "keywords": ["aurelia", "plugin", "ui"],
  "files": ["dist"]
}

Mono-Repository Setup

For complex plugins with multiple packages, consider using a workspace:

{
  "name": "@my-org/ui-library",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "build": "npm run build --workspaces",
    "test": "npm test --workspaces",
    "lint": "eslint packages/*/src/**/*.ts"
  }
}

Directory structure:

my-ui-library/
├── packages/
│   ├── core/             # Core plugin
│   ├── charts/           # Chart components
│   ├── forms/            # Form components
│   └── themes/           # Theme packages
├── examples/             # Example applications
├── docs/                 # Documentation
└── package.json

This setup allows you to:

  • Share common dependencies

  • Cross-reference packages easily

  • Build and test everything together

  • Publish individual packages as needed

Summary

Building Aurelia plugins involves:

  1. Basic Structure: Implement the register method pattern

  2. Configuration: Use DI.createInterface() and the .customize() pattern

  3. Lifecycle Integration: Leverage AppTask for initialization and cleanup

  4. Best Practices: Follow naming conventions, handle errors gracefully, and provide TypeScript support

  5. Testing: Write comprehensive tests for registration and functionality

  6. Distribution: Package properly for npm distribution

By following these patterns and practices, you can create robust, reusable plugins that integrate seamlessly with the Aurelia ecosystem.

Last updated

Was this helpful?