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 +40 -0
- package/CONTRIBUTING.md +65 -0
- package/README.md +97 -0
- package/SECURITY.md +30 -0
- package/package.json +13 -1
- package/scales/community/product-decimal-10.json +10 -0
- package/schemas/community-scale.schema.json +76 -0
- package/src/configs/tailwind.js +8 -0
- package/src/index.js +1 -0
- package/src/presets/index.js +7 -1
- package/src/presets/scales.js +178 -5
- package/src/rules/no-offscale-transform/index.js +47 -5
- package/src/rules/prefer-token/index.js +19 -2
- package/src/rules/use-scale/index.js +31 -1
- package/src/utils/value-utils.js +18 -5
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
|
package/CONTRIBUTING.md
ADDED
|
@@ -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.
|
|
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
|
+
}
|
package/src/index.js
CHANGED
package/src/presets/index.js
CHANGED
|
@@ -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
|
-
|
|
13
|
+
communityScaleMetadata: COMMUNITY_SCALE_METADATA,
|
|
14
|
+
getCommunityScaleMetadata,
|
|
11
15
|
getScalePreset,
|
|
16
|
+
listCommunityScalePresetNames,
|
|
12
17
|
listScalePresetNames,
|
|
18
|
+
scales: SCALE_PRESETS,
|
|
13
19
|
};
|
package/src/presets/scales.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
146
|
-
if (node.type === '
|
|
147
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/utils/value-utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|