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()
.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:
Basic Structure: Implement the
register
method patternConfiguration: Use
DI.createInterface()
and the.customize()
patternLifecycle Integration: Leverage
AppTask
for initialization and cleanupBest Practices: Follow naming conventions, handle errors gracefully, and provide TypeScript support
Testing: Write comprehensive tests for registration and functionality
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?