first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# @n8n/stylelint-config
|
||||
|
||||
Stylelint configuration for n8n projects with custom CSS variable naming convention enforcement.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-configured stylelint rules for SCSS/Sass and Vue files
|
||||
- Custom CSS variable naming convention based on the [proposal.md](../../../proposal.md)
|
||||
- Automatic enforcement via pre-commit hooks
|
||||
- CI/CD integration
|
||||
|
||||
## Installation
|
||||
|
||||
This package is already configured in n8n frontend packages. To use it in a new package:
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"@n8n/stylelint-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Create a `stylelint.config.mjs` file:
|
||||
|
||||
```javascript
|
||||
import { baseConfig } from '@n8n/stylelint-config/base';
|
||||
|
||||
export default baseConfig;
|
||||
```
|
||||
|
||||
Add scripts to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache",
|
||||
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Variable Naming Convention
|
||||
|
||||
The `@n8n/css-var-naming` rule enforces a structured naming pattern for CSS custom properties (variables).
|
||||
|
||||
### Pattern Structure
|
||||
|
||||
```
|
||||
--[namespace--][component[--part]--]property--value[--variant][--state][--mode][--media]
|
||||
```
|
||||
|
||||
**Required groups:**
|
||||
- `property`: Property name from vocabulary (color, background, spacing, etc.)
|
||||
- `value`: Semantic name or scale value
|
||||
|
||||
**Optional groups (in order):**
|
||||
- `namespace`: `n8n`, `chat`, or `p` (for primitives)
|
||||
- `component`: Component name (e.g., `button`, `input`)
|
||||
- `part`: Sub-component (e.g., `menu`, `tab`, `arrow`)
|
||||
- `variant`: Visual style (e.g., `solid`, `outline`, `ghost`)
|
||||
- `state`: Interaction state (e.g., `hover`, `active`, `focus`, `disabled`)
|
||||
- `mode`: Theme/environment (e.g., `light`, `dark`, `hc`)
|
||||
- `media`: Breakpoint (e.g., `sm`, `md`, `lg`)
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Pattern**: Double dash `--` between groups, single dash `-` within groups (kebab-case)
|
||||
2. **Case**: Lowercase alphanumerics only
|
||||
3. **Groups**: Minimum 2, maximum 8 groups
|
||||
4. **Order**: Groups must follow the canonical order shown above
|
||||
|
||||
### Valid Examples
|
||||
|
||||
```css
|
||||
/* Global tokens */
|
||||
--color--primary: #0d6efd;
|
||||
--spacing--md: 20px;
|
||||
--text-color--muted: #888;
|
||||
|
||||
/* With namespace */
|
||||
--n8n--color--primary: #0d6efd;
|
||||
--chat--button--background--primary: #0d6efd;
|
||||
--p--color--primary-500: #0d6efd;
|
||||
--color--neutral-850: #2e3440;
|
||||
|
||||
/* Component tokens */
|
||||
--button--background--primary: #0d6efd;
|
||||
--button--text-color--on-primary: #fff;
|
||||
--tabs--tab--text-color--muted: #888;
|
||||
|
||||
/* With states */
|
||||
--button--background--primary--hover: #0b5ed7;
|
||||
--input--border-color--primary--focus: blue;
|
||||
|
||||
/* With variants */
|
||||
--button--background--primary--solid: #0d6efd;
|
||||
--button--background--primary--outline: transparent;
|
||||
|
||||
/* With variants and states */
|
||||
--button--background--primary--solid--hover: #0b5ed7;
|
||||
--button--background--primary--outline--focus: rgba(13, 110, 253, 0.5);
|
||||
|
||||
/* With modes */
|
||||
--color--primary--dark: #66a3ff;
|
||||
--background--surface--dark: #000;
|
||||
|
||||
/* Complex patterns */
|
||||
--n8n--button--background--primary--solid--hover--dark: #0a58ca;
|
||||
```
|
||||
|
||||
### Invalid Examples
|
||||
|
||||
```css
|
||||
/* ❌ Single dash between groups */
|
||||
--color-primary: #0d6efd;
|
||||
|
||||
/* ❌ Only one group */
|
||||
--primary: #0d6efd;
|
||||
|
||||
/* ❌ Uppercase letters */
|
||||
--Color--Primary: #0d6efd;
|
||||
|
||||
/* ❌ Missing property from vocabulary */
|
||||
--button--main--primary: #0d6efd;
|
||||
|
||||
/* ❌ Invalid value (too short) */
|
||||
--color--xyz: #0d6efd;
|
||||
|
||||
/* ❌ Wrong order (state before variant) */
|
||||
--button--background--primary--hover--solid: #0d6efd;
|
||||
|
||||
/* ❌ Missing required property */
|
||||
--p--gray-740: #2e3440;
|
||||
```
|
||||
|
||||
## Property Vocabulary
|
||||
|
||||
The following properties are recognized:
|
||||
|
||||
| Property | CSS Mapping | Description |
|
||||
|----------|-------------|-------------|
|
||||
| `color` | `color` | Text/element color |
|
||||
| `text-color` | `color` | Text-specific color |
|
||||
| `background` | `background-color` | Background color |
|
||||
| `border-color` | `border-color` | Border color |
|
||||
| `border-width` | `border-width` | Border width |
|
||||
| `icon-color` | `fill`/`stroke` | Icon color |
|
||||
| `radius` | `border-radius` | Border radius |
|
||||
| `shadow` | `box-shadow` | Box shadow |
|
||||
| `spacing` | `margin`/`padding` | Spacing scale |
|
||||
| `font-size` | `font-size` | Font size |
|
||||
| `font-weight` | `font-weight` | Font weight |
|
||||
| `line-height` | `line-height` | Line height |
|
||||
| `z` | `z-index` | Z-index |
|
||||
| `duration` | `transition-duration` | Animation duration |
|
||||
| `easing` | `transition-timing-function` | Animation easing |
|
||||
| `outline-color` | `outline-color` | Outline color |
|
||||
| `outline-width` | `outline-width` | Outline width |
|
||||
|
||||
## Value Types
|
||||
|
||||
### Semantic Values
|
||||
|
||||
Use semantic names for values when possible:
|
||||
|
||||
- **Colors**: `primary`, `secondary`, `success`, `warning`, `danger`, `info`, `muted`, `surface`, `on-primary`, `on-surface`
|
||||
- **Variants**: `solid`, `outline`, `ghost`, `link`, `soft`, `subtle`
|
||||
- **States**: `hover`, `active`, `focus`, `focus-visible`, `visited`, `disabled`, `selected`, `checked`, `invalid`, `opened`, `closed`, `loading`
|
||||
- **Modes**: `light`, `dark`, `hc` (high-contrast), `rtl`, `print`
|
||||
|
||||
### Scale Values
|
||||
|
||||
Use scale values for sizes and spacing:
|
||||
|
||||
- **Size scales**: `none`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`
|
||||
- **Special**: `pill`, `full`
|
||||
- **Font weights**: `regular`, `medium`, `semibold`, `bold`
|
||||
- **Numeric scales**: `primary-100`, `primary-500`, `danger-700`
|
||||
|
||||
### Custom Values
|
||||
|
||||
Descriptive value names with 4+ characters are also accepted:
|
||||
- `base`, `thin`, `thick`, `fast`, `slow`, `modal`, `tooltip`, `purple`, `teal`
|
||||
|
||||
## Usage in CSS/SCSS/Vue
|
||||
|
||||
### Global Tokens
|
||||
|
||||
Define global design tokens in a central location:
|
||||
|
||||
```scss
|
||||
:root {
|
||||
--color--primary: #0d6efd;
|
||||
--color--secondary: #6c757d;
|
||||
--spacing--sm: 8px;
|
||||
--spacing--md: 16px;
|
||||
--spacing--lg: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
### Component Tokens
|
||||
|
||||
Create component-specific tokens that reference globals:
|
||||
|
||||
```scss
|
||||
:root {
|
||||
--button--background--primary: var(--color--primary);
|
||||
--button--text-color--on-primary: #ffffff;
|
||||
--button--radius--md: var(--radius--md);
|
||||
--button--padding--md: var(--spacing--md);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```scss
|
||||
.button {
|
||||
background: var(--button--background--primary);
|
||||
color: var(--button--text-color--on-primary);
|
||||
border-radius: var(--button--radius--md);
|
||||
padding: var(--button--padding--md);
|
||||
|
||||
&:hover {
|
||||
background: var(--button--background--primary--hover);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Theming
|
||||
|
||||
Override variables for different themes:
|
||||
|
||||
```scss
|
||||
:root {
|
||||
--color--primary: #0d6efd;
|
||||
--color--surface: #ffffff;
|
||||
--button--background--primary: var(--color--primary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color--primary: #66a3ff;
|
||||
--color--surface: #0f1115;
|
||||
/* button tokens automatically update through var() references */
|
||||
}
|
||||
```
|
||||
|
||||
### Namespaced Tokens
|
||||
|
||||
Use namespaces for cross-app libraries:
|
||||
|
||||
```scss
|
||||
/* In @n8n/design-system */
|
||||
:root {
|
||||
--n8n--color--primary: #ff6d5a;
|
||||
--n8n--button--background--primary: var(--n8n--color--primary);
|
||||
}
|
||||
|
||||
/* In @n8n/chat */
|
||||
:root {
|
||||
--chat--color--primary: #0d6efd;
|
||||
--chat--button--background--primary: var(--chat--color--primary);
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The stylelint rule runs automatically in:
|
||||
|
||||
1. **Pre-commit hooks** (via lefthook):
|
||||
- Validates CSS variables before commit
|
||||
- Auto-fixes issues when possible
|
||||
|
||||
2. **GitHub Actions CI**:
|
||||
- Runs on all pull requests
|
||||
- Blocks merging if violations are found
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd packages/@n8n/stylelint-config
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Testing the Rule
|
||||
|
||||
```bash
|
||||
# Test on a specific file
|
||||
pnpm stylelint "path/to/file.scss" --config stylelint.config.mjs
|
||||
|
||||
# Test with auto-fix
|
||||
pnpm stylelint "path/to/file.scss" --fix --config stylelint.config.mjs
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Rule not being applied
|
||||
- **Solution**: Make sure package is built: `pnpm build`
|
||||
|
||||
**Issue**: Too many violations in existing codebase
|
||||
- **Solution**: Use `--fix` to auto-fix pattern issues, or add `/* stylelint-disable @n8n/css-var-naming */` temporarily
|
||||
|
||||
**Issue**: False positive for a valid pattern
|
||||
- **Solution**: Check the pattern follows the canonical structure. If it's a legitimate case, please file an issue.
|
||||
|
||||
### Disabling the Rule
|
||||
|
||||
To disable for a specific line:
|
||||
```css
|
||||
/* stylelint-disable-next-line @n8n/css-var-naming */
|
||||
--legacy-var-name: value;
|
||||
```
|
||||
|
||||
To disable for a file:
|
||||
```css
|
||||
/* stylelint-disable @n8n/css-var-naming */
|
||||
```
|
||||
|
||||
To disable in config (not recommended):
|
||||
```javascript
|
||||
export default {
|
||||
...baseConfig,
|
||||
rules: {
|
||||
...baseConfig.rules,
|
||||
'@n8n/css-var-naming': null,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Stylelint Documentation](https://stylelint.io/)
|
||||
- [n8n Design System](../frontend/@n8n/design-system/)
|
||||
|
||||
## Contributing
|
||||
|
||||
When modifying the rule:
|
||||
|
||||
1. Update the rule in `src/rules/css-var-naming.ts`
|
||||
2. Add/update tests in `src/rules/css-var-naming.test.ts`
|
||||
3. Run tests: `pnpm test`
|
||||
4. Build: `pnpm build`
|
||||
5. Update this README if needed
|
||||
|
||||
All tests must pass before submitting changes.
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@n8n/stylelint-config",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"exports": {
|
||||
"./base": {
|
||||
"default": "./dist/configs/base.js",
|
||||
"types": "./dist/configs/base.d.ts"
|
||||
},
|
||||
"./rules": {
|
||||
"default": "./dist/rules/index.js",
|
||||
"types": "./dist/rules/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:dev": "jest --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"stylelint": "^16.23.0",
|
||||
"stylelint-config-standard-scss": "^15.0.1",
|
||||
"stylelint-scss": "^6.12.1",
|
||||
"postcss-html": "^1.8.0",
|
||||
"postcss-scss": "^4.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"rimraf": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"stylelint": ">= 16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Config } from 'stylelint';
|
||||
import { cssVarNaming } from '../rules/index.js';
|
||||
|
||||
export const baseConfig: Config = {
|
||||
// TODO: Extending with standard config requires a lot of manual fixes but would be great to have
|
||||
// extends: 'stylelint-config-standard-scss',
|
||||
// Basic SCSS support with essential rules
|
||||
plugins: ['stylelint-scss', cssVarNaming],
|
||||
rules: {
|
||||
'@n8n/css-var-naming': [true, { severity: 'warning' }],
|
||||
'no-empty-source': true,
|
||||
|
||||
// Basic syntax and consistency rules
|
||||
'color-hex-length': 'short',
|
||||
'comment-no-empty': true,
|
||||
// 'declaration-block-no-duplicate-properties': disabled due to vendor prefixes
|
||||
'no-duplicate-selectors': true,
|
||||
'no-invalid-double-slash-comments': true,
|
||||
|
||||
// Quality rules (keep only the working ones)
|
||||
'length-zero-no-unit': true,
|
||||
// 'no-descending-specificity': disabled - too many existing issues (would require major refactoring) but this would be a must have
|
||||
'no-duplicate-at-import-rules': true,
|
||||
'shorthand-property-no-redundant-values': true,
|
||||
// 'declaration-block-no-shorthand-property-overrides': disabled - conflicts with intentional CSS patterns
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: [
|
||||
'tailwind',
|
||||
'apply',
|
||||
'variants',
|
||||
'responsive',
|
||||
'screen',
|
||||
'use',
|
||||
'forward',
|
||||
'include',
|
||||
'mixin',
|
||||
'function',
|
||||
'return',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'each',
|
||||
'while',
|
||||
'extend',
|
||||
'at-root',
|
||||
'warn',
|
||||
'error',
|
||||
],
|
||||
},
|
||||
],
|
||||
'at-rule-disallowed-list': [
|
||||
['import'],
|
||||
{
|
||||
message:
|
||||
'@import is deprecated! Use @use for local SCSS files. For third-party libraries that need scoping: use @use "sass:meta"; and @include meta.load-css("library") inside a CSS selector.',
|
||||
},
|
||||
],
|
||||
|
||||
// SCSS specific rules
|
||||
'scss/dollar-variable-colon-space-after': 'always-single-line',
|
||||
'scss/dollar-variable-colon-space-before': 'never',
|
||||
'scss/dollar-variable-no-missing-interpolation': true,
|
||||
'scss/double-slash-comment-whitespace-inside': 'always',
|
||||
'scss/operator-no-unspaced': true,
|
||||
'scss/property-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['composes'],
|
||||
},
|
||||
],
|
||||
'scss/at-import-partial-extension-disallowed-list': ['scss'],
|
||||
// 'scss/selector-no-redundant-nesting-selector': disabled - would require manual fixes across many files
|
||||
},
|
||||
ignoreFiles: [
|
||||
'**/node_modules/**/*',
|
||||
'**/dist/**/*',
|
||||
'**/build/**/*',
|
||||
'**/.turbo/**/*',
|
||||
'**/coverage/**/*',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
customSyntax: 'postcss-html',
|
||||
},
|
||||
{
|
||||
files: ['**/*.scss', '**/*.sass'],
|
||||
customSyntax: 'postcss-scss',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default baseConfig;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
import stylelint from 'stylelint';
|
||||
import type { Rule } from 'stylelint';
|
||||
|
||||
const ruleName = '@n8n/css-var-naming';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
rejected: (variable: string, reason: string) => `Invalid CSS variable "${variable}": ${reason}`,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
url: 'https://github.com/n8n-io/n8n',
|
||||
};
|
||||
|
||||
// Reserved vocabulary from proposal.md
|
||||
// NOTE: color--text, color--background, color--foreground use double dashes
|
||||
// to separate "color" from the subtype (text/background/foreground)
|
||||
const PROPERTY_VOCABULARY = new Set([
|
||||
'color',
|
||||
'color--text',
|
||||
'color--background',
|
||||
'color--foreground',
|
||||
'border-color',
|
||||
'border-width',
|
||||
'border-top-color',
|
||||
'border-bottom-color',
|
||||
'border-right-color',
|
||||
'border-left-width',
|
||||
'border-style',
|
||||
'border',
|
||||
'height',
|
||||
'icon-color',
|
||||
'radius',
|
||||
'size',
|
||||
'stroke-width',
|
||||
'shadow',
|
||||
'spacing',
|
||||
'padding',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'line-height',
|
||||
'margin',
|
||||
'margin-right',
|
||||
'margin-left',
|
||||
'margin-top',
|
||||
'margin-bottom',
|
||||
'max-height',
|
||||
'max-width',
|
||||
'min-height',
|
||||
'min-width',
|
||||
'z',
|
||||
'duration',
|
||||
'easing',
|
||||
'offset',
|
||||
'outline-color',
|
||||
'outline-width',
|
||||
'width',
|
||||
'top',
|
||||
'bottom',
|
||||
'left',
|
||||
'right',
|
||||
]);
|
||||
|
||||
// Properties that can be used as standalone single-group variables (without a value)
|
||||
const STANDALONE_PROPERTIES = new Set([
|
||||
'shadow',
|
||||
'radius',
|
||||
'border-color',
|
||||
'border-style',
|
||||
'border-width',
|
||||
'border',
|
||||
'font-family',
|
||||
]);
|
||||
|
||||
const STATES = new Set([
|
||||
'hover',
|
||||
'active',
|
||||
'focus',
|
||||
'focus-visible',
|
||||
'visited',
|
||||
'disabled',
|
||||
'selected',
|
||||
'checked',
|
||||
'invalid',
|
||||
'opened',
|
||||
'closed',
|
||||
'loading',
|
||||
]);
|
||||
|
||||
const VARIANTS = new Set(['solid', 'outline', 'ghost', 'link', 'soft', 'subtle']);
|
||||
|
||||
const MODES = new Set(['light', 'dark', 'hc', 'rtl', 'print']);
|
||||
|
||||
const MEDIA = new Set(['sm', 'md', 'lg', 'xl', '2xl']);
|
||||
|
||||
// Ignore issues related to these namespaces
|
||||
const DISABLE_CHECK_FOR_NAMESPACES = new Set(['reka', 'ag', 'chat']);
|
||||
|
||||
// Allowed namespaces
|
||||
const NAMESPACES = new Set(['n8n', 'p', ...DISABLE_CHECK_FOR_NAMESPACES]);
|
||||
|
||||
// Semantic values and scales
|
||||
const SEMANTIC_VALUES = new Set([
|
||||
'primary',
|
||||
'secondary',
|
||||
'success',
|
||||
'warning',
|
||||
'danger',
|
||||
'info',
|
||||
'muted',
|
||||
'surface',
|
||||
'on-primary',
|
||||
'on-surface',
|
||||
]);
|
||||
|
||||
const SCALE_VALUES = new Set([
|
||||
'5xs',
|
||||
'4xs',
|
||||
'3xs',
|
||||
'2xs',
|
||||
'xs',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
'2xl',
|
||||
'3xl',
|
||||
'4xl',
|
||||
'5xl',
|
||||
]);
|
||||
|
||||
// Font weight specific values (only valid with font-weight property)
|
||||
const FONT_WEIGHT_VALUES = new Set(['regular', 'medium', 'semibold', 'bold']);
|
||||
|
||||
// Regex for basic validation
|
||||
// Allows 2-10 groups to accommodate double-dash properties like color--text
|
||||
const BASIC_PATTERN = /^--[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*){1,9}$/;
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function shouldSkip(variable: string) {
|
||||
// Split into groups first (drop first empty element from leading --)
|
||||
const parts = variable.slice(2).split('-');
|
||||
if (DISABLE_CHECK_FOR_NAMESPACES.has(parts[0])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateCssVariable(variable: string): ValidationResult {
|
||||
if (shouldSkip(variable)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Split into groups first (drop first empty element from leading --)
|
||||
const groups = variable.slice(2).split('--');
|
||||
|
||||
// Check if this is a single-group variable (e.g., --shadow, --radius, --border-color)
|
||||
if (groups.length === 1) {
|
||||
const singleGroup = groups[0];
|
||||
// Allow standalone properties that are in the STANDALONE_PROPERTIES set
|
||||
if (STANDALONE_PROPERTIES.has(singleGroup)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Must have at least 2 groups separated by double dashes (--property--value minimum)',
|
||||
};
|
||||
}
|
||||
|
||||
// Basic pattern check for multi-group variables
|
||||
if (!BASIC_PATTERN.test(variable)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
'Must follow pattern: --[group]--[group]--... with lowercase alphanumerics and single dash within groups',
|
||||
};
|
||||
}
|
||||
|
||||
// Check group count (2-10 groups to accommodate double-dash properties like color--text)
|
||||
if (groups.length < 2) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Must have at least 2 groups separated by double dashes (--property--value minimum)',
|
||||
};
|
||||
}
|
||||
|
||||
if (groups.length > 10) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Must have at most 10 groups (too many segments)',
|
||||
};
|
||||
}
|
||||
|
||||
// Check each group for invalid characters
|
||||
for (const group of groups) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(group)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Group "${group}" contains invalid characters. Use only lowercase letters, numbers, and single dash within groups`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if first group is a namespace, and if so, validate it
|
||||
const firstGroup = groups[0];
|
||||
let startIndex = 0;
|
||||
|
||||
// If first group is a valid namespace, skip it for property validation
|
||||
if (NAMESPACES.has(firstGroup)) {
|
||||
startIndex = 1;
|
||||
}
|
||||
|
||||
// Validate property vocabulary (should be in the variable somewhere after namespace)
|
||||
const hasValidProperty = groups.slice(startIndex).some((group) => PROPERTY_VOCABULARY.has(group));
|
||||
if (!hasValidProperty) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Must include a valid property from vocabulary: ${Array.from(PROPERTY_VOCABULARY).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the property index to validate what comes after it
|
||||
const propertyIndex = groups
|
||||
.slice(startIndex)
|
||||
.findIndex((group) => PROPERTY_VOCABULARY.has(group));
|
||||
const absolutePropertyIndex = startIndex + propertyIndex;
|
||||
|
||||
// Check if any semantic or scale values appear before the property
|
||||
const groupsBeforeProperty = groups.slice(startIndex, absolutePropertyIndex);
|
||||
for (const group of groupsBeforeProperty) {
|
||||
// Check if this group is a semantic value, scale value, or font-weight value
|
||||
if (SEMANTIC_VALUES.has(group) || SCALE_VALUES.has(group) || FONT_WEIGHT_VALUES.has(group)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Value "${group}" appears before the property. Values must come after the property (e.g., --color--${group}, not --${group}--color)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get the property name to validate specific property-value combinations
|
||||
const propertyName = groups[absolutePropertyIndex];
|
||||
|
||||
// Check if HSL components (h, s, l) appear in non-final positions or as suffixes
|
||||
const hslComponents = new Set(['h', 's', 'l']);
|
||||
|
||||
// Check all groups after property for HSL-related issues
|
||||
for (let i = absolutePropertyIndex + 1; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
const isLastGroup = i === groups.length - 1;
|
||||
|
||||
// Check if group is exactly h, s, or l (allowed only at the end)
|
||||
if (hslComponents.has(group)) {
|
||||
if (!isLastGroup) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `HSL component "${group}" must be at the end of the variable name (e.g., --color--primary--${group}, not --color--${group}--primary)`,
|
||||
};
|
||||
}
|
||||
// If it's the last group and exactly h/s/l, it's valid
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if group ends with -h, -s, or -l (never allowed)
|
||||
if (group.endsWith('-h') || group.endsWith('-s') || group.endsWith('-l')) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `HSL component suffix in "${group}" is not allowed. Use standalone HSL components instead (e.g., --color--primary--h, not --color--primary-h)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The group after property should be a value (semantic or scale)
|
||||
if (absolutePropertyIndex + 1 < groups.length) {
|
||||
const valueGroup = groups[absolutePropertyIndex + 1];
|
||||
|
||||
// Check if this is a font-weight specific value
|
||||
if (FONT_WEIGHT_VALUES.has(valueGroup)) {
|
||||
// Font weight values are only valid with font-weight property
|
||||
if (propertyName !== 'font-weight') {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Value "${valueGroup}" can only be used with font-weight property (e.g., --font-weight--${valueGroup})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a known modifier (variant, state, mode, media)
|
||||
const isModifier =
|
||||
VARIANTS.has(valueGroup) ||
|
||||
STATES.has(valueGroup) ||
|
||||
MODES.has(valueGroup) ||
|
||||
MEDIA.has(valueGroup);
|
||||
|
||||
// If it's not a modifier, validate it's a semantic or scale value
|
||||
// We use a permissive approach: reject only clearly invalid patterns
|
||||
if (!isModifier) {
|
||||
const isValidValue =
|
||||
SEMANTIC_VALUES.has(valueGroup) ||
|
||||
SCALE_VALUES.has(valueGroup) ||
|
||||
FONT_WEIGHT_VALUES.has(valueGroup) ||
|
||||
// Allow color shades like "primary-500", "shade-50", "tint-50"
|
||||
/^[a-z]+-\d+$/.test(valueGroup) ||
|
||||
// Allow descriptive names (3+ chars) - these are likely intentional semantic names
|
||||
valueGroup.length >= 3 ||
|
||||
// Support hsl css variables (only allowed at the end, checked above)
|
||||
hslComponents.has(valueGroup);
|
||||
|
||||
if (!isValidValue) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Value "${valueGroup}" is too short. Use semantic values (${Array.from(SEMANTIC_VALUES).slice(0, 5).join(', ')}...) or scale values (${Array.from(SCALE_VALUES).slice(0, 5).join(', ')}...). See proposal for full list.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for states/variants/modes in appropriate positions (optional validation)
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
|
||||
// If last group is a state/mode/media, that's valid
|
||||
if (STATES.has(lastGroup) || MODES.has(lastGroup) || MEDIA.has(lastGroup)) {
|
||||
// Valid pattern
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Check if we have variants in reasonable positions
|
||||
const hasVariant = groups.some((group) => VARIANTS.has(group));
|
||||
const hasState = groups.some((group) => STATES.has(group));
|
||||
|
||||
// If we have both variant and state, variant should come before state
|
||||
if (hasVariant && hasState) {
|
||||
const variantIndex = groups.findIndex((group) => VARIANTS.has(group));
|
||||
const stateIndex = groups.findIndex((group) => STATES.has(group));
|
||||
if (variantIndex > stateIndex) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
'Variant should come before state (e.g., --button--background--primary--solid--hover)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const ruleFunction: Rule = (primary, secondaryOptions, context) => {
|
||||
return (root, result) => {
|
||||
const validOptions = stylelint.utils.validateOptions(result, ruleName, {
|
||||
actual: primary,
|
||||
});
|
||||
|
||||
if (!validOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop;
|
||||
|
||||
// Only check CSS custom properties (variables)
|
||||
if (!prop.startsWith('--')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateCssVariable(prop);
|
||||
|
||||
if (!validation.valid) {
|
||||
stylelint.utils.report({
|
||||
message: messages.rejected(prop, validation.reason!),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Also check variable usage in var() functions
|
||||
root.walkDecls((decl) => {
|
||||
const value = decl.value;
|
||||
|
||||
// Find all var() references
|
||||
const varPattern = /var\((--[a-z0-9-]+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = varPattern.exec(value)) !== null) {
|
||||
const variable = match[1];
|
||||
const validation = validateCssVariable(variable);
|
||||
|
||||
if (!validation.valid) {
|
||||
stylelint.utils.report({
|
||||
message: messages.rejected(variable, validation.reason!),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
ruleFunction.ruleName = ruleName;
|
||||
ruleFunction.messages = messages;
|
||||
ruleFunction.meta = meta;
|
||||
|
||||
export default stylelint.createPlugin(ruleName, ruleFunction);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as cssVarNaming } from './css-var-naming.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2015",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user