Skip to content

Custom Lint Plugins

Extend Dispersa’s linting system with custom rules and plugins tailored to your design system’s needs.

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 } })
}
}
},
})
PropertyTypeDescription
metaLintRuleMetaRule metadata (name, description, messages)
defaultOptionsOptions (optional)Default configuration
create()(context) => void | PromiseValidation logic

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
}

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'] }]
}

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
}
}
},
})

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',
},
},
},
}
{
meta:interface LintPlugin {
name: string // Package name
version?: string // Semantic version
}
rules: Record<string, LintRule> // Rules provided
configs?: Record<string, LintConfig> // Predefined configs
}

Use declaration merging to add type safety for your custom rules:

// In your plugin's types file
import '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!
}

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',
},
})

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

lint-plugins/my-design-system.ts
import { createRule } from 'dispersa/lint'
import type { LintPlugin } from 'dispersa/lint'
// Rule 1: Require token type
const 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 prefixes
const 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 plugin
export 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' })],
})