Custom Lint Plugins
Extend Dispersa’s linting system with custom rules and plugins tailored to your design system’s needs.
Creating custom rules
Section titled “Creating custom rules”Use createRule() to build type-safe lint rules:
import { createRule } from 'dispersa/lint'
const noPrimaryColors = createRule<'NO_PRIMARY'>({ meta: { name: 'no-primary-colors', description: 'Disallow "primary" in token names', messages: { NO_PRIMARY: "Token '{{name}}' contains 'primary'. Use semantic names instead.", }, }, create({ tokens, report }) { for (const token of Object.values(tokens)) { if (token.name.includes('primary')) { report({ token, messageId: 'NO_PRIMARY', data: { name: token.name } }) } } },})Rule structure
Section titled “Rule structure”| Property | Type | Description |
|---|---|---|
meta | LintRuleMeta | Rule metadata (name, description, messages) |
defaultOptions | Options (optional) | Default configuration |
create() | (context) => void | Promise | Validation logic |
Rule context
Section titled “Rule context”The create() function receives a context object:
interface LintRuleContext { /** Fully qualified rule ID */ id: string /** Merged options (default + user config) */ options: Options /** All resolved tokens */ tokens: InternalResolvedTokens /** Report a lint issue */ report(descriptor: LintReportDescriptor): void}Options
Section titled “Options”Rules can accept configurable options:
const requireType = createRule<'MISSING_TYPE'>({ meta: { name: 'require-type', description: 'Require $type on all tokens', messages: { MISSING_TYPE: "Token '{{name}}' is missing $type", }, }, defaultOptions: { types: ['color', 'dimension'] }, create({ tokens, options, report }) { const requiredTypes = options.types ?? []
for (const token of Object.values(tokens)) { if (requiredTypes.includes(token.$type ?? '')) { if (!token.$type) { report({ token, messageId: 'MISSING_TYPE', data: { name: token.name } }) } } } },})Use as:
rules: { 'custom/require-type': ['error', { types: ['color', 'dimension', 'fontFamily'] }]}Accessing all tokens
Section titled “Accessing all tokens”Rules can reference other tokens for complex validation:
const noCyclicAliases = createRule<'CYCLIC'>({ meta: { name: 'no-cyclic-aliases', description: 'Detect cyclic alias references', messages: { CYCLIC: "Token '{{name}}' has a cyclic reference", }, }, create({ tokens, report }) { for (const [name, token] of Object.entries(tokens)) { const visited = new Set<string>() let current = token.originalValue
while (typeof current === 'string' && current.startsWith('{') && current.endsWith('}')) { const ref = current.slice(1, -1) if (visited.has(ref)) { report({ token, messageId: 'CYCLIC', data: { name } }) break } visited.add(ref) current = tokens[ref]?.originalValue } } },})Creating plugins
Section titled “Creating plugins”Plugins bundle multiple rules with shared configuration:
import type { LintPlugin, LintConfig } from 'dispersa/lint'import { requireType } from './rules/require-type'import { noLegacyPrefix } from './rules/no-legacy-prefix'
const myPlugin: LintPlugin = { meta: { name: 'my-design-system', version: '1.0.0', }, rules: { 'require-type': requireType, 'no-legacy-prefix': noLegacyPrefix, }, configs: { recommended: { plugins: { my: myPlugin }, rules: { 'my/require-type': 'error', 'my/no-legacy-prefix': 'warn', }, }, },}Plugin structure
Section titled “Plugin structure” { meta:interface LintPlugin { name: string // Package name version?: string // Semantic version } rules: Record<string, LintRule> // Rules provided configs?: Record<string, LintConfig> // Predefined configs}Type-safe configuration
Section titled “Type-safe configuration”Use declaration merging to add type safety for your custom rules:
// In your plugin's types fileimport 'dispersa/lint'
declare module 'dispersa/lint' { interface RulesRegistry { 'my-plugin/require-type': { types: string[] } 'my-plugin/no-legacy-prefix': { allowedPrefixes: string[] } }}This provides autocomplete when configuring rules:
rules: { 'my-plugin/require-type': ['error', { types: ['color'] }] // autocomplete!}Loading plugins
Section titled “Loading plugins”Inline plugins
Section titled “Inline plugins”Pass plugins directly in configuration:
import { lint } from 'dispersa'
const result = await lint({ resolver: './tokens.resolver.json', plugins: { myPlugin, dispersa: dispersaPlugin, }, rules: { 'dispersa/require-description': 'warn', 'myPlugin/require-type': 'error', },})Load by name
Section titled “Load by name”Use a plugin loader to load from packages:
import { PluginLoader } from 'dispersa/lint'
const loader = new PluginLoader()
const config = await loader.loadConfig({ plugins: { a11y: '@dispersa/lint-plugin-a11y', },})The loader can resolve:
- Package names (e.g.,
@dispersa/lint-plugin-a11y) - File paths
- Already-loaded plugin objects
Example: Complete custom plugin
Section titled “Example: Complete custom plugin”import { createRule } from 'dispersa/lint'import type { LintPlugin } from 'dispersa/lint'
// Rule 1: Require token typeconst requireType = createRule<'MISSING_TYPE'>({ meta: { name: 'require-type', description: 'Require $type on all tokens', messages: { MISSING_TYPE: "Token '{{name}}' is missing $type", }, }, create({ tokens, report }) { for (const token of Object.values(tokens)) { if (!token.$type) { report({ token, messageId: 'MISSING_TYPE', data: { name: token.name } }) } } },})
// Rule 2: No legacy prefixesconst noLegacyPrefix = createRule<'LEGACY_PREFIX'>({ meta: { name: 'no-legacy-prefix', description: 'Disallow legacy token prefixes', messages: { LEGACY_PREFIX: "Token '{{name}}' uses legacy prefix '{{prefix}}'", }, }, defaultOptions: { allowedPrefixes: ['ds-', 'token-'] }, create({ tokens, options, report }) { const allowed = options.allowedPrefixes ?? []
for (const token of Object.values(tokens)) { for (const prefix of allowed) { if (token.name.startsWith(prefix)) { report({ token, messageId: 'LEGACY_PREFIX', data: { name: token.name, prefix }, }) } } } },})
// Build the pluginexport const myDesignSystemPlugin: LintPlugin = { meta: { name: '@myorg/dispersa-lint-plugin', version: '1.0.0', }, rules: { 'require-type': requireType, 'no-legacy-prefix': noLegacyPrefix, }, configs: { recommended: { plugins: { myds: myDesignSystemPlugin }, rules: { 'myds/require-type': 'error', 'myds/no-legacy-prefix': 'warn', }, }, },}Use in your build:
import { css, build } from 'dispersa'import { myDesignSystemPlugin } from './lint-plugins/my-design-system'
const result = await build({ resolver: './tokens.resolver.json', lint: { enabled: true, plugins: { myds: myDesignSystemPlugin }, rules: { 'myds/require-type': 'error', 'myds/no-legacy-prefix': ['warn', { allowedPrefixes: ['ds-', 'design-'] }], }, }, outputs: [css({ name: 'css', file: 'tokens.css', preset: 'bundle' })],})