stylelint-plugin-rhythmguard 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to `stylelint-plugin-rhythmguard` will be documented in this
4
4
 
5
5
  The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [1.2.0] - 2026-02-17
10
+
11
+ ### Added
12
+
13
+ - Tailwind integration guidance in README:
14
+ - exact enforcement boundary (CSS declarations vs class strings)
15
+ - recommended layered setup with `stylelint-config-tailwindcss`, `eslint-plugin-tailwindcss`, and `prettier-plugin-tailwindcss`
16
+ - architecture direction for thorough Tailwind coverage.
17
+ - New shared config entry point: `stylelint-plugin-rhythmguard/configs/tailwind`.
18
+ - Tailwind-oriented test coverage for transform token functions and nested translate values.
19
+
20
+ ### Changed
21
+
22
+ - Hardened transform translate parsing to handle nested function values consistently.
23
+ - `use-scale` and `no-offscale-transform` now respect `enforceInsideMathFunctions` in transform translation contexts.
24
+
25
+ ## [1.1.0] - 2026-02-17
26
+
27
+ ### Added
28
+
29
+ - `CODEOWNERS` for repository ownership and review routing.
30
+ - Post-publish npm smoke workflow to validate clean-project install and lint execution from the registry.
31
+ - Non-blocking full-suite observability on Stylelint `16.0.0` in CI/release verification.
32
+ - Community scale registry with JSON schema, CI validation, and scaffolding script.
33
+ - Community contribution workflow assets:
34
+ - `docs/COMMUNITY_SCALES.md`
35
+ - `scales/community/*.json`
36
+ - `scripts/scales/add-scale.mjs`
37
+ - `scripts/scales/validate-community-scales.mjs`
38
+ - scale request issue template.
39
+
40
+ ### Changed
41
+
42
+ - Preset loader now includes validated community scale files from `scales/community`.
43
+ - Exported preset helpers now include:
44
+ - `listCommunityScalePresetNames()`
45
+ - `getCommunityScaleMetadata(name)`
46
+
7
47
  ## [1.0.0] - 2026-02-17
8
48
 
9
49
  ### Changed
@@ -0,0 +1,65 @@
1
+ # Contributing
2
+
3
+ Thanks for contributing to `stylelint-plugin-rhythmguard`.
4
+
5
+ ## Development Setup
6
+
7
+ ```bash
8
+ npm ci
9
+ npm run lint
10
+ npm test
11
+ ```
12
+
13
+ Optional checks:
14
+
15
+ ```bash
16
+ npm run test:compat-floor
17
+ npm run test:coverage
18
+ npm run test:pack-smoke
19
+ npm run scales:validate
20
+ ```
21
+
22
+ ## Community Scale Contributions
23
+
24
+ Rhythmguard accepts community presets through JSON files in `scales/community`.
25
+
26
+ Create a new scale file:
27
+
28
+ ```bash
29
+ npm run scales:add -- --name my-team-scale --base 8 --steps 0,4,8,12,16,24,32
30
+ ```
31
+
32
+ Then validate:
33
+
34
+ ```bash
35
+ npm run scales:validate
36
+ ```
37
+
38
+ Scale files must pass schema and collision checks. See [`docs/COMMUNITY_SCALES.md`](./docs/COMMUNITY_SCALES.md) for the full spec and policy.
39
+
40
+ ## Semver Rules
41
+
42
+ - Patch (`x.y.Z`): bug fixes, docs updates, non-breaking internal changes.
43
+ - Minor (`x.Y.z`): backward-compatible new options, presets, or behavior.
44
+ - Major (`X.y.z`): any breaking behavior change for existing rules/configs.
45
+
46
+ Breaking examples:
47
+
48
+ - changing default scale behavior
49
+ - changing autofix behavior in a non-compatible way
50
+ - changing/removing exported config entry points
51
+
52
+ ## Rule Change Requirements
53
+
54
+ When changing rule logic:
55
+
56
+ 1. add/adjust tests for the behavior
57
+ 2. validate deterministic fix behavior
58
+ 3. update README option or behavior docs if needed
59
+ 4. update CHANGELOG
60
+
61
+ ## Release Notes
62
+
63
+ - Keep `stylelint-plugin-rhythmguard/configs/recommended` and `stylelint-plugin-rhythmguard/configs/strict` stable.
64
+ - Verify CI matrix is green before publishing.
65
+ - Publish with provenance through the release workflow.
package/README.md CHANGED
@@ -61,10 +61,20 @@ npm install --save-dev stylelint stylelint-plugin-rhythmguard
61
61
  }
62
62
  ```
63
63
 
64
+ ### Tailwind config
65
+
66
+ ```json
67
+ {
68
+ "plugins": ["stylelint-plugin-rhythmguard"],
69
+ "extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
70
+ }
71
+ ```
72
+
64
73
  Stable shared config entry points:
65
74
 
66
75
  - `stylelint-plugin-rhythmguard/configs/recommended`
67
76
  - `stylelint-plugin-rhythmguard/configs/strict`
77
+ - `stylelint-plugin-rhythmguard/configs/tailwind`
68
78
 
69
79
  ### Full custom setup
70
80
 
@@ -183,6 +193,36 @@ Aliases:
183
193
  - Theory presets expose mathematically-derived modular scales from design theory and typographic proportion systems.
184
194
  - Full research notes and sources are documented in [`docs/SCALE_RESEARCH.md`](./docs/SCALE_RESEARCH.md).
185
195
 
196
+ ## Community Scale Registry
197
+
198
+ Rhythmguard supports community-contributed scale presets from `scales/community/*.json`.
199
+
200
+ ### Current community scales
201
+
202
+ | Preset | Base | Pattern | Contributor |
203
+ | --- | --- | --- | --- |
204
+ | `product-decimal-10` | `10` | Decimal-friendly dashboard/product cadence | [Petri Lahdelma](https://github.com/PetriLahdelma) |
205
+
206
+ ### Contribute a scale
207
+
208
+ 1. Scaffold a new scale file:
209
+
210
+ ```bash
211
+ npm run scales:add -- --name my-team-scale --base 8 --steps 0,4,8,12,16,24,32
212
+ ```
213
+
214
+ 2. Validate:
215
+
216
+ ```bash
217
+ npm run scales:validate
218
+ ```
219
+
220
+ 3. Open a PR with your scale JSON.
221
+
222
+ Full specification and policy: [`docs/COMMUNITY_SCALES.md`](./docs/COMMUNITY_SCALES.md).
223
+
224
+ If your scale is private or very niche, keep it in your project config with `customScale` instead of contributing it to the shared registry.
225
+
186
226
  ## Rule Details
187
227
 
188
228
  ### `rhythmguard/use-scale`
@@ -287,12 +327,67 @@ Options:
287
327
 
288
328
  `rhythmguard/no-offscale-transform` accepts the same scale options as `rhythmguard/use-scale`, but only for transform translation properties.
289
329
 
330
+ ## Tailwind CSS Integration
331
+
332
+ Rhythmguard works well in Tailwind projects, but it enforces what Stylelint can parse: CSS declarations.
333
+
334
+ ### What Rhythmguard covers in Tailwind projects
335
+
336
+ - custom CSS in `globals.css`, `components.css`, `utilities.css`
337
+ - CSS Modules (for example `*.module.css`)
338
+ - declarations inside `@layer` blocks
339
+
340
+ ### What Rhythmguard does not cover
341
+
342
+ - Tailwind class strings in templates/JSX/TSX, for example:
343
+ - `class="p-4 gap-2"`
344
+ - `class="p-[13px] translate-y-[18px]"`
345
+
346
+ Those are not Stylelint declaration nodes, so they are outside this plugin's scope.
347
+
348
+ ### Recommended stack for full Tailwind enforcement
349
+
350
+ Use both layers:
351
+
352
+ 1. Stylelint + Rhythmguard for CSS declaration governance.
353
+ 2. Tailwind-aware class-string linting/formatting for template utility usage.
354
+
355
+ Suggested setup:
356
+
357
+ ```json
358
+ {
359
+ "extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
360
+ }
361
+ ```
362
+
363
+ Then pair with:
364
+
365
+ - `eslint-plugin-tailwindcss` for class-string rules (including arbitrary-value governance).
366
+ - `prettier-plugin-tailwindcss` for deterministic class ordering.
367
+
368
+ Detailed setup reference: [`docs/TAILWIND.md`](./docs/TAILWIND.md).
369
+
370
+ ### Tailwind token function support
371
+
372
+ By default, `tokenFunctions` includes `theme`, so values like `theme(spacing.4)` are treated as tokenized values.
373
+
374
+ ### Product direction
375
+
376
+ We should extend Tailwind coverage thoroughly, but in the right architecture:
377
+
378
+ - keep `stylelint-plugin-rhythmguard` focused on CSS declaration enforcement
379
+ - add a complementary Tailwind class-string layer (ESLint/plugin side) for utility classes
380
+
381
+ This avoids brittle parsing hacks and gives full coverage without compromising rule quality.
382
+
290
383
  ## Programmatic Presets
291
384
 
292
385
  ```js
293
386
  const rhythmguard = require('stylelint-plugin-rhythmguard');
294
387
 
295
388
  console.log(rhythmguard.presets.listScalePresetNames());
389
+ console.log(rhythmguard.presets.listCommunityScalePresetNames());
390
+ console.log(rhythmguard.presets.getCommunityScaleMetadata('product-decimal-10'));
296
391
  console.log(rhythmguard.presets.scales['rhythmic-4']);
297
392
  ```
298
393
 
@@ -310,6 +405,7 @@ It will not guess token mappings without your map.
310
405
  - Stylelint: `^16.0.0`
311
406
  - Node.js: `>=18.18.0`
312
407
  - Module format: CommonJS plugin package
408
+ - Note: Stylelint `16.0.0` has known autofix/API behavior differences; CI enforces floor compatibility and runs non-blocking full-suite observability on the floor version.
313
409
 
314
410
  ## Development
315
411
 
@@ -342,6 +438,7 @@ Detailed methodology and custom args are documented in [`docs/BENCHMARKING.md`](
342
438
  2. `release.yml` runs the Node/Stylelint matrix validation.
343
439
  3. A tarball smoke test validates package exports and install behavior.
344
440
  4. The package is published to npm with provenance (`npm publish --provenance`).
441
+ 5. `post-publish-smoke.yml` verifies the published npm version can be installed and run in a clean project.
345
442
 
346
443
  ## Support and Bug Reports
347
444
 
package/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ | --- | --- |
7
+ | 0.1.x | Yes |
8
+ | < 0.1.0 | No |
9
+
10
+ ## Reporting a Vulnerability
11
+
12
+ Please do **not** open public GitHub issues for security reports.
13
+
14
+ Use one of these channels:
15
+
16
+ 1. GitHub private vulnerability reporting (preferred): open a GitHub Security Advisory draft in this repository.
17
+ 2. Email: `hello@petrilahdelma.com`
18
+
19
+ Please include:
20
+
21
+ - affected version
22
+ - reproduction steps or proof of concept
23
+ - impact assessment
24
+ - any suggested mitigation
25
+
26
+ ## Response Targets
27
+
28
+ - Initial acknowledgment: within 72 hours
29
+ - Triage decision: within 7 days
30
+ - Remediation/release timeline: communicated after triage based on severity and exploitability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Stylelint plugin for spacing scale and token enforcement",
5
5
  "keywords": [
6
6
  "stylelint",
@@ -8,6 +8,7 @@
8
8
  "css",
9
9
  "design-system",
10
10
  "design-tokens",
11
+ "spacing-scale",
11
12
  "spacing",
12
13
  "linting"
13
14
  ],
@@ -17,6 +18,7 @@
17
18
  ".": "./src/index.js",
18
19
  "./configs/recommended": "./src/configs/recommended.js",
19
20
  "./configs/strict": "./src/configs/strict.js",
21
+ "./configs/tailwind": "./src/configs/tailwind.js",
20
22
  "./presets": "./src/presets/index.js",
21
23
  "./rules/use-scale": "./src/rules/use-scale/index.js",
22
24
  "./rules/prefer-token": "./src/rules/prefer-token/index.js",
@@ -24,16 +26,23 @@
24
26
  },
25
27
  "files": [
26
28
  "assets",
29
+ "scales",
30
+ "schemas",
27
31
  "src",
28
32
  "README.md",
29
33
  "CHANGELOG.md",
34
+ "CONTRIBUTING.md",
35
+ "SECURITY.md",
30
36
  "LICENSE"
31
37
  ],
32
38
  "scripts": {
33
39
  "bench:perf": "node scripts/bench/compare.mjs",
34
40
  "bench:perf:fix": "node scripts/bench/compare.mjs --fix",
35
41
  "lint": "eslint .",
42
+ "scales:add": "node scripts/scales/add-scale.mjs",
43
+ "scales:validate": "node scripts/scales/validate-community-scales.mjs",
36
44
  "test:pack-smoke": "node scripts/ci/pack-smoke.mjs",
45
+ "test:npm-smoke": "node scripts/ci/npm-registry-smoke.mjs --package stylelint-plugin-rhythmguard --version latest",
37
46
  "test": "node --test test/*.test.js",
38
47
  "test:compat-floor": "node --test test/floor-compat.test.js",
39
48
  "test:watch": "node --test --watch test/*.test.js",
@@ -51,6 +60,9 @@
51
60
  "peerDependencies": {
52
61
  "stylelint": "^16.0.0"
53
62
  },
63
+ "dependencies": {
64
+ "stylelint-config-tailwindcss": "^1.0.1"
65
+ },
54
66
  "devDependencies": {
55
67
  "@eslint/js": "^9.22.0",
56
68
  "c8": "^10.1.3",
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "product-decimal-10",
3
+ "description": "Decimal-friendly product spacing scale for dashboard-heavy enterprise UIs.",
4
+ "base": 10,
5
+ "steps": [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64, 80],
6
+ "aliases": ["decimal-10", "enterprise-10"],
7
+ "tags": ["product", "dashboard", "community"],
8
+ "contributor": "Petri Lahdelma",
9
+ "contributorUrl": "https://github.com/PetriLahdelma"
10
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/schemas/community-scale.schema.json",
4
+ "title": "Rhythmguard Community Scale",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "name",
9
+ "description",
10
+ "base",
11
+ "steps"
12
+ ],
13
+ "properties": {
14
+ "name": {
15
+ "type": "string",
16
+ "description": "Preset identifier used with the `preset` option.",
17
+ "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "Short purpose statement for docs and gallery.",
22
+ "minLength": 12,
23
+ "maxLength": 220
24
+ },
25
+ "base": {
26
+ "type": "number",
27
+ "description": "Primary grid base in px.",
28
+ "minimum": 0
29
+ },
30
+ "steps": {
31
+ "type": "array",
32
+ "description": "Ascending px scale values. Must start at 0.",
33
+ "minItems": 3,
34
+ "maxItems": 48,
35
+ "uniqueItems": true,
36
+ "items": {
37
+ "type": "number",
38
+ "minimum": 0,
39
+ "maximum": 2048
40
+ }
41
+ },
42
+ "aliases": {
43
+ "type": "array",
44
+ "description": "Optional shorthand aliases for preset lookup.",
45
+ "maxItems": 16,
46
+ "uniqueItems": true,
47
+ "items": {
48
+ "type": "string",
49
+ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
50
+ },
51
+ "default": []
52
+ },
53
+ "tags": {
54
+ "type": "array",
55
+ "description": "Discovery tags shown in docs/gallery.",
56
+ "maxItems": 16,
57
+ "uniqueItems": true,
58
+ "items": {
59
+ "type": "string",
60
+ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
61
+ },
62
+ "default": []
63
+ },
64
+ "contributor": {
65
+ "type": "string",
66
+ "description": "Contributor name used in gallery attribution.",
67
+ "minLength": 2,
68
+ "maxLength": 120
69
+ },
70
+ "contributorUrl": {
71
+ "type": "string",
72
+ "description": "Optional contributor profile URL.",
73
+ "format": "uri"
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ extends: [
5
+ 'stylelint-config-tailwindcss',
6
+ 'stylelint-plugin-rhythmguard/configs/strict',
7
+ ],
8
+ };
package/src/index.js CHANGED
@@ -15,5 +15,6 @@ module.exports.rules = {
15
15
  module.exports.configs = {
16
16
  recommended: require('./configs/recommended'),
17
17
  strict: require('./configs/strict'),
18
+ tailwind: require('./configs/tailwind'),
18
19
  };
19
20
  module.exports.presets = require('./presets');
@@ -1,13 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  const {
4
+ COMMUNITY_SCALE_METADATA,
4
5
  SCALE_PRESETS,
6
+ getCommunityScaleMetadata,
5
7
  getScalePreset,
8
+ listCommunityScalePresetNames,
6
9
  listScalePresetNames,
7
10
  } = require('./scales');
8
11
 
9
12
  module.exports = {
10
- scales: SCALE_PRESETS,
13
+ communityScaleMetadata: COMMUNITY_SCALE_METADATA,
14
+ getCommunityScaleMetadata,
11
15
  getScalePreset,
16
+ listCommunityScalePresetNames,
12
17
  listScalePresetNames,
18
+ scales: SCALE_PRESETS,
13
19
  };
@@ -1,5 +1,16 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ function normalizePresetName(name) {
7
+ if (typeof name !== 'string') {
8
+ return '';
9
+ }
10
+
11
+ return name.trim().toLowerCase();
12
+ }
13
+
3
14
  function createModularScale({ base, ratio, steps }) {
4
15
  const values = [0];
5
16
  let current = base;
@@ -12,7 +23,7 @@ function createModularScale({ base, ratio, steps }) {
12
23
  return [...new Set(values)].sort((a, b) => a - b);
13
24
  }
14
25
 
15
- const SCALE_PRESETS = Object.freeze({
26
+ const CORE_SCALE_PRESETS = Object.freeze({
16
27
  'rhythmic-4': Object.freeze([0, 4, 8, 12, 16, 24, 32, 40, 48, 64]),
17
28
  'rhythmic-8': Object.freeze([0, 8, 16, 24, 32, 40, 48, 64, 80, 96]),
18
29
  'product-material-8dp': Object.freeze([0, 4, 8, 12, 16, 24, 32, 40, 48, 56, 64, 72, 80]),
@@ -32,7 +43,7 @@ const SCALE_PRESETS = Object.freeze({
32
43
  'modular-perfect-fifth': Object.freeze(createModularScale({ base: 4, ratio: 1.5, steps: 12 })),
33
44
  });
34
45
 
35
- const PRESET_ALIASES = Object.freeze({
46
+ const CORE_PRESET_ALIASES = Object.freeze({
36
47
  '4pt': 'rhythmic-4',
37
48
  '8pt': 'rhythmic-8',
38
49
  'atlassian-8': 'product-atlassian-8px',
@@ -49,10 +60,151 @@ const PRESET_ALIASES = Object.freeze({
49
60
  'perfect-fourth': 'modular-perfect-fourth',
50
61
  });
51
62
 
52
- function normalizePresetName(name) {
53
- return String(name).trim().toLowerCase();
63
+ function normalizeCommunitySteps(steps) {
64
+ if (!Array.isArray(steps) || steps.length === 0) {
65
+ return null;
66
+ }
67
+
68
+ const normalized = [];
69
+ let previous = null;
70
+
71
+ for (const value of steps) {
72
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
73
+ return null;
74
+ }
75
+
76
+ if (previous !== null && value <= previous) {
77
+ return null;
78
+ }
79
+
80
+ normalized.push(value);
81
+ previous = value;
82
+ }
83
+
84
+ if (normalized[0] !== 0) {
85
+ return null;
86
+ }
87
+
88
+ return Object.freeze(normalized);
54
89
  }
55
90
 
91
+ function loadCommunityScales() {
92
+ const communityDirectory = path.resolve(__dirname, '../../scales/community');
93
+ if (!fs.existsSync(communityDirectory)) {
94
+ return [];
95
+ }
96
+
97
+ const namesInUse = new Set(Object.keys(CORE_SCALE_PRESETS));
98
+ const aliasesInUse = new Set([...Object.keys(CORE_SCALE_PRESETS), ...Object.keys(CORE_PRESET_ALIASES)]);
99
+
100
+ const fileNames = fs
101
+ .readdirSync(communityDirectory, { withFileTypes: true })
102
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
103
+ .map((entry) => entry.name)
104
+ .sort((a, b) => a.localeCompare(b));
105
+
106
+ const definitions = [];
107
+
108
+ for (const fileName of fileNames) {
109
+ const absolutePath = path.join(communityDirectory, fileName);
110
+
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
119
+ continue;
120
+ }
121
+
122
+ const presetName = normalizePresetName(parsed.name);
123
+ if (!presetName || namesInUse.has(presetName)) {
124
+ continue;
125
+ }
126
+
127
+ const steps = normalizeCommunitySteps(parsed.steps);
128
+ if (!steps) {
129
+ continue;
130
+ }
131
+
132
+ const aliases = [];
133
+ if (Array.isArray(parsed.aliases)) {
134
+ for (const aliasEntry of parsed.aliases) {
135
+ const alias = normalizePresetName(aliasEntry);
136
+ if (!alias || alias === presetName || aliasesInUse.has(alias)) {
137
+ continue;
138
+ }
139
+
140
+ aliases.push(alias);
141
+ aliasesInUse.add(alias);
142
+ }
143
+ }
144
+
145
+ namesInUse.add(presetName);
146
+ aliasesInUse.add(presetName);
147
+
148
+ definitions.push(
149
+ Object.freeze({
150
+ aliases: Object.freeze(aliases),
151
+ base: typeof parsed.base === 'number' && Number.isFinite(parsed.base) ? parsed.base : null,
152
+ contributor: typeof parsed.contributor === 'string' ? parsed.contributor : null,
153
+ contributorUrl: typeof parsed.contributorUrl === 'string' ? parsed.contributorUrl : null,
154
+ description: typeof parsed.description === 'string' ? parsed.description : null,
155
+ fileName,
156
+ name: presetName,
157
+ steps,
158
+ tags: Object.freeze(Array.isArray(parsed.tags) ? parsed.tags.filter((tag) => typeof tag === 'string') : []),
159
+ }),
160
+ );
161
+ }
162
+
163
+ return definitions;
164
+ }
165
+
166
+ const COMMUNITY_SCALE_DEFINITIONS = Object.freeze(loadCommunityScales());
167
+
168
+ const COMMUNITY_SCALE_PRESETS = Object.freeze(
169
+ Object.fromEntries(COMMUNITY_SCALE_DEFINITIONS.map((definition) => [definition.name, definition.steps])),
170
+ );
171
+
172
+ const COMMUNITY_PRESET_ALIASES = Object.freeze(
173
+ Object.assign(
174
+ {},
175
+ ...COMMUNITY_SCALE_DEFINITIONS.map((definition) =>
176
+ Object.fromEntries(definition.aliases.map((alias) => [alias, definition.name])),
177
+ ),
178
+ ),
179
+ );
180
+
181
+ const COMMUNITY_SCALE_METADATA = Object.freeze(
182
+ Object.fromEntries(
183
+ COMMUNITY_SCALE_DEFINITIONS.map((definition) => [
184
+ definition.name,
185
+ Object.freeze({
186
+ aliases: definition.aliases,
187
+ base: definition.base,
188
+ contributor: definition.contributor,
189
+ contributorUrl: definition.contributorUrl,
190
+ description: definition.description,
191
+ fileName: definition.fileName,
192
+ tags: definition.tags,
193
+ }),
194
+ ]),
195
+ ),
196
+ );
197
+
198
+ const SCALE_PRESETS = Object.freeze({
199
+ ...CORE_SCALE_PRESETS,
200
+ ...COMMUNITY_SCALE_PRESETS,
201
+ });
202
+
203
+ const PRESET_ALIASES = Object.freeze({
204
+ ...CORE_PRESET_ALIASES,
205
+ ...COMMUNITY_PRESET_ALIASES,
206
+ });
207
+
56
208
  function resolvePresetName(name) {
57
209
  if (typeof name !== 'string' || name.trim().length === 0) {
58
210
  return null;
@@ -72,7 +224,20 @@ function getScalePreset(name) {
72
224
  }
73
225
 
74
226
  function listScalePresetNames() {
75
- return Object.keys(SCALE_PRESETS);
227
+ return Object.keys(SCALE_PRESETS).sort((a, b) => a.localeCompare(b));
228
+ }
229
+
230
+ function listCommunityScalePresetNames() {
231
+ return Object.keys(COMMUNITY_SCALE_PRESETS).sort((a, b) => a.localeCompare(b));
232
+ }
233
+
234
+ function getCommunityScaleMetadata(name) {
235
+ const normalizedName = resolvePresetName(name);
236
+ if (!normalizedName) {
237
+ return null;
238
+ }
239
+
240
+ return COMMUNITY_SCALE_METADATA[normalizedName] || null;
76
241
  }
77
242
 
78
243
  function resolveScaleSelection(options, defaultScale) {
@@ -115,8 +280,16 @@ function resolveScaleSelection(options, defaultScale) {
115
280
  }
116
281
 
117
282
  module.exports = {
283
+ COMMUNITY_SCALE_DEFINITIONS,
284
+ COMMUNITY_SCALE_METADATA,
285
+ COMMUNITY_SCALE_PRESETS,
286
+ CORE_PRESET_ALIASES,
287
+ CORE_SCALE_PRESETS,
288
+ PRESET_ALIASES,
118
289
  SCALE_PRESETS,
290
+ getCommunityScaleMetadata,
119
291
  getScalePreset,
292
+ listCommunityScalePresetNames,
120
293
  listScalePresetNames,
121
294
  resolveScaleSelection,
122
295
  };
@@ -14,6 +14,8 @@ const {
14
14
  const { buildScaleOptions } = require('../../utils/options');
15
15
  const {
16
16
  declarationValueIndex,
17
+ isMathFunction,
18
+ walkRootValueNodes,
17
19
  walkTransformTranslateNodes,
18
20
  } = require('../../utils/value-utils');
19
21
 
@@ -138,15 +140,55 @@ const ruleFunction = (primary, secondaryOptions) => {
138
140
  };
139
141
 
140
142
  if (prop === 'transform') {
141
- walkTransformTranslateNodes(parsed, (node) => {
143
+ walkTransformTranslateNodes(parsed, (node, parentFunctionName) => {
144
+ if (node.type === 'function') {
145
+ if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
146
+ return true;
147
+ }
148
+
149
+ return false;
150
+ }
151
+
152
+ if (node.type !== 'word') {
153
+ return false;
154
+ }
155
+
156
+ if (
157
+ parentFunctionName &&
158
+ isMathFunction(parentFunctionName) &&
159
+ !options.enforceInsideMathFunctions
160
+ ) {
161
+ return false;
162
+ }
163
+
142
164
  checkNode(node);
165
+ return false;
143
166
  });
144
167
  } else {
145
- for (const node of parsed.nodes) {
146
- if (node.type === 'word') {
147
- checkNode(node);
168
+ walkRootValueNodes(parsed, (node, parentFunctionName) => {
169
+ if (node.type === 'function') {
170
+ if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
171
+ return true;
172
+ }
173
+
174
+ return false;
148
175
  }
149
- }
176
+
177
+ if (node.type !== 'word') {
178
+ return false;
179
+ }
180
+
181
+ if (
182
+ parentFunctionName &&
183
+ isMathFunction(parentFunctionName) &&
184
+ !options.enforceInsideMathFunctions
185
+ ) {
186
+ return false;
187
+ }
188
+
189
+ checkNode(node);
190
+ return false;
191
+ });
150
192
  }
151
193
 
152
194
  if (changed) {
@@ -142,8 +142,25 @@ const ruleFunction = (primary, secondaryOptions) => {
142
142
  };
143
143
 
144
144
  if (prop === 'transform') {
145
- walkTransformTranslateNodes(parsed, (node) => {
146
- changed = checkWordNode(node) || changed;
145
+ walkTransformTranslateNodes(parsed, (node, parentFunctionName) => {
146
+ if (node.type === 'function') {
147
+ if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
148
+ return true;
149
+ }
150
+
151
+ if (isMathFunction(node.value)) {
152
+ return true;
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ if (node.type !== 'word') {
159
+ return false;
160
+ }
161
+
162
+ changed = checkWordNode(node, parentFunctionName) || changed;
163
+ return false;
147
164
  });
148
165
  } else {
149
166
  walkRootValueNodes(parsed, (node, parentFunctionName) => {
@@ -170,7 +170,35 @@ const ruleFunction = (primary, secondaryOptions) => {
170
170
  let changed = false;
171
171
 
172
172
  if (prop === 'transform') {
173
- walkTransformTranslateNodes(parsed, (node) => {
173
+ walkTransformTranslateNodes(parsed, (node, parentFunctionName) => {
174
+ if (node.type === 'function') {
175
+ if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
176
+ return true;
177
+ }
178
+
179
+ if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
180
+ return true;
181
+ }
182
+
183
+ return false;
184
+ }
185
+
186
+ if (node.type !== 'word') {
187
+ return false;
188
+ }
189
+
190
+ if (isKeyword(node.value, options.ignoreValues)) {
191
+ return false;
192
+ }
193
+
194
+ if (
195
+ parentFunctionName &&
196
+ isMathFunction(parentFunctionName) &&
197
+ !options.enforceInsideMathFunctions
198
+ ) {
199
+ return false;
200
+ }
201
+
174
202
  changed =
175
203
  checkLengthValue({
176
204
  decl,
@@ -179,6 +207,8 @@ const ruleFunction = (primary, secondaryOptions) => {
179
207
  report,
180
208
  scalePx,
181
209
  }) || changed;
210
+
211
+ return false;
182
212
  });
183
213
  } else {
184
214
  walkRootValueNodes(parsed, (node, parentFunctionName) => {
@@ -77,6 +77,23 @@ function walkRootValueNodes(parsed, walkNode, state) {
77
77
  }
78
78
 
79
79
  function walkTransformTranslateNodes(parsed, walkNode) {
80
+ const walkNodes = (nodes, parentFunctionName) => {
81
+ for (const node of nodes) {
82
+ if (node.type === 'function') {
83
+ const fnName = node.value.toLowerCase();
84
+ const skipChildren = walkNode(node, parentFunctionName);
85
+ if (skipChildren) {
86
+ continue;
87
+ }
88
+
89
+ walkNodes(node.nodes, fnName);
90
+ continue;
91
+ }
92
+
93
+ walkNode(node, parentFunctionName);
94
+ }
95
+ };
96
+
80
97
  for (const node of parsed.nodes) {
81
98
  if (node.type !== 'function') {
82
99
  continue;
@@ -86,11 +103,7 @@ function walkTransformTranslateNodes(parsed, walkNode) {
86
103
  continue;
87
104
  }
88
105
 
89
- for (const child of node.nodes) {
90
- if (child.type === 'word') {
91
- walkNode(child, node.value.toLowerCase());
92
- }
93
- }
106
+ walkNodes(node.nodes, node.value.toLowerCase());
94
107
  }
95
108
  }
96
109