@ncksnydr/config-js 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # @ncksnydr/config-js
2
2
 
3
- Shared configuration for TypeScript, ESLint, Prettier and Vitest. Each configuration is a separate entry point, so a Consumer uses only the parts it needs.
3
+ Shared configuration for TypeScript, ESLint, Prettier, Stylelint and Vitest. Each configuration is a separate entry point, so a Consumer uses only the parts it needs.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- pnpm add --save-dev @ncksnydr/config-js typescript eslint prettier vitest
8
+ pnpm add --save-dev @ncksnydr/config-js typescript eslint prettier stylelint vitest
9
9
  ```
10
10
 
11
11
  ## TypeScript
@@ -96,6 +96,41 @@ Checking formatting...
96
96
 
97
97
  To fix the layout, run `prettier --write .`.
98
98
 
99
+ ## Stylelint
100
+
101
+ Use the configuration as your `stylelint.config.js`:
102
+
103
+ ```js
104
+ export { default } from "@ncksnydr/config-js/stylelint";
105
+ ```
106
+
107
+ The configuration needs Stylelint 18 or later. Then lint your CSS:
108
+
109
+ ```sh
110
+ stylelint "**/*.css"
111
+ ```
112
+
113
+ The configuration holds these rules:
114
+
115
+ - Colors use `oklch()`. Hex colors, named colors and `rgb()`, `rgba()`, `hsl()` and `hsla()` fail.
116
+ - Primitive tokens, such as `var(--palette-blue-500)` or `var(--scale-3)`, are allowed only in files inside a `tokens/` folder. Other files use semantic tokens.
117
+ - Each `var()` has a fallback, such as `var(--space-md, 1.5rem)`. A custom property that the same file declares needs no fallback.
118
+ - `!important` and `brightness()` fail.
119
+ - Each rule sits inside one of the layers `reset`, `tokens`, `base`, `components` or `utilities`. A top-level `@media` block is the one exception.
120
+ - Top-level `@media` blocks come last in the file.
121
+ - Rules nest at most 3 levels deep.
122
+ - Class names follow BEM in kebab case: `block`, `block__element`, `block--modifier`.
123
+ - Units are limited to `rem`, `px`, `%`, `fr`, `vh`, `dvh`, `svh`, `vw`, `deg`, `ms` and `s`.
124
+ - Each `px` or `rem` length is `1px` or a multiple of `3px`. The check counts `1rem` as `16px`.
125
+ - Custom properties come first, then declarations, then nested rules, then nested at-rules. Declarations are in alphabetical order.
126
+
127
+ A CSS file that breaks a rule fails the check:
128
+
129
+ ```text
130
+ card.css
131
+ 3:12 ✖ Disallowed hex color "#fff" color-no-hex
132
+ ```
133
+
99
134
  ## Vitest
100
135
 
101
136
  Use the preset as your `vitest.config.ts`:
@@ -0,0 +1,2 @@
1
+ import stylelint from "stylelint";
2
+ export declare const layered: stylelint.Plugin;
@@ -0,0 +1,47 @@
1
+ import stylelint from "stylelint";
2
+ const { createPlugin, utils: { report, ruleMessages, validateOptions }, } = stylelint;
3
+ const ruleName = "nks/layered";
4
+ const HOUSE_LAYERS = new Set(["reset", "tokens", "base", "components", "utilities"]);
5
+ const houseLayerList = new Intl.ListFormat("en-GB", { type: "disjunction" }).format(HOUSE_LAYERS);
6
+ const messages = ruleMessages(ruleName, {
7
+ outside: `Put this rule inside @layer ${houseLayerList}.`,
8
+ undeclared: (name) => `'${name}' is not a house layer. Use ${houseLayerList}.`,
9
+ });
10
+ function isAtRule(node) {
11
+ return node.type === "atrule";
12
+ }
13
+ function isLayeredOrExempt(styleRule) {
14
+ for (let parent = styleRule.parent; parent && parent.type !== "root"; parent = parent.parent) {
15
+ if (parent.type === "rule") {
16
+ return true;
17
+ }
18
+ if (isAtRule(parent)) {
19
+ const isTopLevelMedia = parent.name === "media" && parent.parent?.type === "root";
20
+ if (parent.name === "layer" || parent.name.endsWith("keyframes") || isTopLevelMedia) {
21
+ return true;
22
+ }
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+ const rule = (primary) => (root, result) => {
28
+ if (!validateOptions(result, ruleName, { actual: primary })) {
29
+ return;
30
+ }
31
+ root.walkAtRules("layer", (atRule) => {
32
+ const name = atRule.params.split(".")[0]?.trim() ?? "";
33
+ const isLayerBlock = atRule.nodes !== undefined;
34
+ if (isLayerBlock && !HOUSE_LAYERS.has(name)) {
35
+ report({ message: messages.undeclared(name), node: atRule, result, ruleName });
36
+ }
37
+ });
38
+ root.walkRules((styleRule) => {
39
+ if (!isLayeredOrExempt(styleRule)) {
40
+ report({ message: messages.outside, node: styleRule, result, ruleName });
41
+ }
42
+ });
43
+ };
44
+ rule.ruleName = ruleName;
45
+ rule.messages = messages;
46
+ export const layered = createPlugin(ruleName, rule);
47
+ //# sourceMappingURL=layered.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layered.js","sourceRoot":"","sources":["../../src/stylelint/layered.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import stylelint from "stylelint";
2
+ export declare const mediaLast: stylelint.Plugin;
@@ -0,0 +1,27 @@
1
+ import stylelint from "stylelint";
2
+ const { createPlugin, utils: { report, ruleMessages, validateOptions }, } = stylelint;
3
+ const ruleName = "nks/media-last";
4
+ const messages = ruleMessages(ruleName, {
5
+ rejected: "Move this above the first top-level @media. Media queries go at the bottom of the file.",
6
+ });
7
+ function isMedia(node) {
8
+ return node.type === "atrule" && node.name === "media";
9
+ }
10
+ const rule = (primary) => (root, result) => {
11
+ if (!validateOptions(result, ruleName, { actual: primary })) {
12
+ return;
13
+ }
14
+ let mediaSeen = false;
15
+ for (const node of root.nodes) {
16
+ if (isMedia(node)) {
17
+ mediaSeen = true;
18
+ }
19
+ else if (mediaSeen && (node.type === "rule" || node.type === "atrule")) {
20
+ report({ message: messages.rejected, node, result, ruleName });
21
+ }
22
+ }
23
+ };
24
+ rule.ruleName = ruleName;
25
+ rule.messages = messages;
26
+ export const mediaLast = createPlugin(ruleName, rule);
27
+ //# sourceMappingURL=mediaLast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mediaLast.js","sourceRoot":"","sources":["../../src/stylelint/mediaLast.ts"],"names":[],"mappings":"AACA,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,MAAM,EACJ,YAAY,EACZ,KAAK,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,eAAe,EAAE,GACjD,GAAG,SAAS,CAAC;AAEd,MAAM,QAAQ,GAAG,gBAAgB,CAAC;AAElC,MAAM,QAAQ,GAAG,YAAY,CAAC,QAAQ,EAAE;IACtC,QAAQ,EAAE,yFAAyF;CACpG,CAAC,CAAC;AAEH,SAAS,OAAO,CAAC,IAAe;IAC9B,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC;AACzD,CAAC;AAED,MAAM,IAAI,GAAmB,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;IACzD,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;QAC5D,OAAO;IACT,CAAC;IAED,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QAC9B,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAClB,SAAS,GAAG,IAAI,CAAC;QACnB,CAAC;aAAM,IAAI,SAAS,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC,EAAE,CAAC;YACzE,MAAM,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;QACjE,CAAC;IACH,CAAC;AACH,CAAC,CAAC;AAEF,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AACzB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAEzB,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ import stylelint from "stylelint";
2
+ export declare const scaleOfThree: stylelint.Plugin;
@@ -0,0 +1,34 @@
1
+ import stylelint from "stylelint";
2
+ const { createPlugin, utils: { report, ruleMessages, validateOptions }, } = stylelint;
3
+ const ruleName = "nks/scale-of-three";
4
+ const messages = ruleMessages(ruleName, {
5
+ rejected: (length) => `${length} is off the scale. Use 1px or a multiple of 3px (rem at 16px).`,
6
+ });
7
+ const LENGTH = /(-?\d*\.?\d+)(px|rem)\b/g;
8
+ const PX_PER_REM = 16;
9
+ const SCALE_STEP_PX = 3;
10
+ const HAIRLINE_PX = 1;
11
+ const ROUNDING_TOLERANCE = 1e-9;
12
+ function isOnScale(amount, unit) {
13
+ const px = Math.abs(unit === "rem" ? amount * PX_PER_REM : amount);
14
+ const steps = px / SCALE_STEP_PX;
15
+ const isWholeStep = Math.abs(steps - Math.round(steps)) < ROUNDING_TOLERANCE;
16
+ return px === HAIRLINE_PX || isWholeStep;
17
+ }
18
+ const rule = (primary) => (root, result) => {
19
+ if (!validateOptions(result, ruleName, { actual: primary })) {
20
+ return;
21
+ }
22
+ root.walkDecls((decl) => {
23
+ for (const [length, amount, unit] of decl.value.matchAll(LENGTH)) {
24
+ const isOffScale = amount !== undefined && unit !== undefined && !isOnScale(Number(amount), unit);
25
+ if (isOffScale) {
26
+ report({ message: messages.rejected(length), node: decl, result, ruleName, word: length });
27
+ }
28
+ }
29
+ });
30
+ };
31
+ rule.ruleName = ruleName;
32
+ rule.messages = messages;
33
+ export const scaleOfThree = createPlugin(ruleName, rule);
34
+ //# sourceMappingURL=scaleOfThree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaleOfThree.js","sourceRoot":"","sources":["../../src/stylelint/scaleOfThree.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,MAAM,EACJ,YAAY,EACZ,KAAK,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,eAAe,EAAE,GACjD,GAAG,SAAS,CAAC;AAEd,MAAM,QAAQ,GAAG,oBAAoB,CAAC;AAEtC,MAAM,QAAQ,GAAG,YAAY,CAAC,QAAQ,EAAE;IACtC,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAAC,GAAG,MAAM,gEAAgE;CACxG,CAAC,CAAC;AAEH,MAAM,MAAM,GAAG,0BAA0B,CAAC;AAC1C,MAAM,UAAU,GAAG,EAAE,CAAC;AACtB,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,MAAM,WAAW,GAAG,CAAC,CAAC;AACtB,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEhC,SAAS,SAAS,CAAC,MAAc,EAAE,IAAY;IAC7C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,EAAE,GAAG,aAAa,CAAC;IACjC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,kBAAkB,CAAC;IAC7E,OAAO,EAAE,KAAK,WAAW,IAAI,WAAW,CAAC;AAC3C,CAAC;AAED,MAAM,IAAI,GAAmB,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;IACzD,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;QAC5D,OAAO;IACT,CAAC;IAED,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;QACtB,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;YACjE,MAAM,UAAU,GAAG,MAAM,KAAK,SAAS,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC;YAClG,IAAI,UAAU,EAAE,CAAC;gBACf,MAAM,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;YAC7F,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AACzB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAEzB,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ import stylelint from "stylelint";
2
+ export declare const varFallback: stylelint.Plugin;
@@ -0,0 +1,28 @@
1
+ import stylelint from "stylelint";
2
+ const { createPlugin, utils: { report, ruleMessages, validateOptions }, } = stylelint;
3
+ const ruleName = "nks/var-fallback";
4
+ const messages = ruleMessages(ruleName, {
5
+ rejected: (name) => `Give var(${name}) a fallback: var(${name}, value).`,
6
+ });
7
+ const BARE_VAR = /var\(\s*(--[\w-]+)\s*\)/g;
8
+ const rule = (primary) => (root, result) => {
9
+ if (!validateOptions(result, ruleName, { actual: primary })) {
10
+ return;
11
+ }
12
+ const declaredInFile = new Set();
13
+ root.walkDecls(/^--/, (decl) => {
14
+ declaredInFile.add(decl.prop);
15
+ });
16
+ root.walkDecls((decl) => {
17
+ for (const [bareVar, name] of decl.value.matchAll(BARE_VAR)) {
18
+ const needsFallback = name !== undefined && !declaredInFile.has(name);
19
+ if (needsFallback) {
20
+ report({ message: messages.rejected(name), node: decl, result, ruleName, word: bareVar });
21
+ }
22
+ }
23
+ });
24
+ };
25
+ rule.ruleName = ruleName;
26
+ rule.messages = messages;
27
+ export const varFallback = createPlugin(ruleName, rule);
28
+ //# sourceMappingURL=varFallback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"varFallback.js","sourceRoot":"","sources":["../../src/stylelint/varFallback.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,MAAM,EACJ,YAAY,EACZ,KAAK,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,eAAe,EAAE,GACjD,GAAG,SAAS,CAAC;AAEd,MAAM,QAAQ,GAAG,kBAAkB,CAAC;AAEpC,MAAM,QAAQ,GAAG,YAAY,CAAC,QAAQ,EAAE;IACtC,QAAQ,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,YAAY,IAAI,qBAAqB,IAAI,WAAW;CACjF,CAAC,CAAC;AAEH,MAAM,QAAQ,GAAG,0BAA0B,CAAC;AAE5C,MAAM,IAAI,GAAmB,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;IACzD,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;QAC5D,OAAO;IACT,CAAC;IAED,MAAM,cAAc,GAAG,IAAI,GAAG,EAAU,CAAC;IACzC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE;QAC7B,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;QACtB,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC5D,MAAM,aAAa,GAAG,IAAI,KAAK,SAAS,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACtE,IAAI,aAAa,EAAE,CAAC;gBAClB,MAAM,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;YAC5F,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AACzB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAEzB,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Config } from "stylelint";
2
+ declare const config: Config;
3
+ export default config;
@@ -0,0 +1,49 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { layered } from "./stylelint/layered.js";
3
+ import { mediaLast } from "./stylelint/mediaLast.js";
4
+ import { scaleOfThree } from "./stylelint/scaleOfThree.js";
5
+ import { varFallback } from "./stylelint/varFallback.js";
6
+ const orderPlugin = fileURLToPath(import.meta.resolve("stylelint-order"));
7
+ const BEM_CLASS = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$/;
8
+ const BRIGHTNESS = /brightness\(/;
9
+ const disallowedEverywhere = { filter: [BRIGHTNESS] };
10
+ function disallowedValueMessage(_property, value) {
11
+ return BRIGHTNESS.test(value)
12
+ ? "Do not use brightness()."
13
+ : "Use a semantic token here. Primitive tokens belong in the token files.";
14
+ }
15
+ const config = {
16
+ overrides: [
17
+ {
18
+ files: ["**/tokens/**/*.css"],
19
+ rules: {
20
+ "declaration-property-value-disallowed-list": [disallowedEverywhere, { message: disallowedValueMessage }],
21
+ },
22
+ },
23
+ ],
24
+ plugins: [layered, mediaLast, orderPlugin, scaleOfThree, varFallback],
25
+ rules: {
26
+ "color-named": "never",
27
+ "color-no-hex": true,
28
+ "declaration-no-important": true,
29
+ "declaration-property-value-disallowed-list": [
30
+ { ...disallowedEverywhere, "/.*/": [/var\(--(palette|scale)-/] },
31
+ { message: disallowedValueMessage },
32
+ ],
33
+ "function-disallowed-list": [["rgb", "rgba", "hsl", "hsla"], { message: "Use oklch()." }],
34
+ "max-nesting-depth": 3,
35
+ "nks/layered": true,
36
+ "nks/media-last": true,
37
+ "nks/scale-of-three": true,
38
+ "nks/var-fallback": true,
39
+ "order/order": ["custom-properties", "declarations", "rules", "at-rules"],
40
+ "order/properties-alphabetical-order": true,
41
+ "selector-class-pattern": [
42
+ BEM_CLASS,
43
+ { message: "Expected BEM kebab-case: block, block__element, block--modifier" },
44
+ ],
45
+ "unit-allowed-list": ["rem", "px", "%", "fr", "vh", "dvh", "svh", "vw", "deg", "ms", "s"],
46
+ },
47
+ };
48
+ export default config;
49
+ //# sourceMappingURL=stylelint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stylelint.js","sourceRoot":"","sources":["../src/stylelint.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,MAAM,WAAW,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAE1E,MAAM,SAAS,GAAG,qFAAqF,CAAC;AAExG,MAAM,UAAU,GAAG,cAAc,CAAC;AAElC,MAAM,oBAAoB,GAAG,EAAE,MAAM,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC;AAEtD,SAAS,sBAAsB,CAAC,SAAiB,EAAE,KAAa;IAC9D,OAAO,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;QAC3B,CAAC,CAAC,0BAA0B;QAC5B,CAAC,CAAC,wEAAwE,CAAC;AAC/E,CAAC;AAED,MAAM,MAAM,GAAW;IACrB,SAAS,EAAE;QACT;YACE,KAAK,EAAE,CAAC,oBAAoB,CAAC;YAC7B,KAAK,EAAE;gBACL,4CAA4C,EAAE,CAAC,oBAAoB,EAAE,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAC;aAC1G;SACF;KACF;IACD,OAAO,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,CAAC;IACrE,KAAK,EAAE;QACL,aAAa,EAAE,OAAO;QACtB,cAAc,EAAE,IAAI;QACpB,0BAA0B,EAAE,IAAI;QAChC,4CAA4C,EAAE;YAC5C,EAAE,GAAG,oBAAoB,EAAE,MAAM,EAAE,CAAC,yBAAyB,CAAC,EAAE;YAChE,EAAE,OAAO,EAAE,sBAAsB,EAAE;SACpC;QACD,0BAA0B,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC;QACzF,mBAAmB,EAAE,CAAC;QACtB,aAAa,EAAE,IAAI;QACnB,gBAAgB,EAAE,IAAI;QACtB,oBAAoB,EAAE,IAAI;QAC1B,kBAAkB,EAAE,IAAI;QACxB,aAAa,EAAE,CAAC,mBAAmB,EAAE,cAAc,EAAE,OAAO,EAAE,UAAU,CAAC;QACzE,qCAAqC,EAAE,IAAI;QAC3C,wBAAwB,EAAE;YACxB,SAAS;YACT,EAAE,OAAO,EAAE,iEAAiE,EAAE;SAC/E;QACD,mBAAmB,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC;KAC1F;CACF,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ncksnydr/config-js",
3
- "version": "0.2.0",
4
- "description": "Shared TypeScript, ESLint, Prettier and Vitest configuration for Nick Snyder's codebases",
3
+ "version": "0.3.0",
4
+ "description": "Shared TypeScript, ESLint, Prettier, Stylelint and Vitest configuration for Nick Snyder's codebases",
5
5
  "repository": {
6
6
  "directory": "packages/config-js",
7
7
  "type": "git",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/prettier.d.ts",
18
18
  "default": "./dist/prettier.js"
19
19
  },
20
+ "./stylelint": {
21
+ "types": "./dist/stylelint.d.ts",
22
+ "default": "./dist/stylelint.js"
23
+ },
20
24
  "./tsconfig": "./tsconfig/base.json",
21
25
  "./vitest": {
22
26
  "types": "./dist/vitest.d.ts",
@@ -34,6 +38,7 @@
34
38
  "peerDependencies": {
35
39
  "eslint": "^10.0.0",
36
40
  "prettier": "^3.5.0",
41
+ "stylelint": "^18.0.0",
37
42
  "typescript": "^6.0.0",
38
43
  "vitest": "^5.0.0"
39
44
  },
@@ -44,6 +49,9 @@
44
49
  "prettier": {
45
50
  "optional": true
46
51
  },
52
+ "stylelint": {
53
+ "optional": true
54
+ },
47
55
  "typescript": {
48
56
  "optional": true
49
57
  },
@@ -56,12 +64,15 @@
56
64
  "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
57
65
  "eslint-import-resolver-typescript": "^4.4.5",
58
66
  "eslint-plugin-import-x": "^4.18.1",
67
+ "stylelint-order": "^8.1.3",
59
68
  "typescript-eslint": "^8.72.2"
60
69
  },
61
70
  "devDependencies": {
62
71
  "@types/node": "^26.6.6",
63
72
  "eslint": "^10.14.0",
73
+ "postcss": "^8.5.32",
64
74
  "prettier": "^3.10.0",
75
+ "stylelint": "^18.0.0",
65
76
  "typescript": "~6.0.3",
66
77
  "vitest": "^5.1.0"
67
78
  },
@@ -0,0 +1,62 @@
1
+ import type { AtRule, Node, Rule } from "postcss";
2
+ import stylelint from "stylelint";
3
+
4
+ const {
5
+ createPlugin,
6
+ utils: { report, ruleMessages, validateOptions },
7
+ } = stylelint;
8
+
9
+ const ruleName = "nks/layered";
10
+
11
+ const HOUSE_LAYERS = new Set(["reset", "tokens", "base", "components", "utilities"]);
12
+
13
+ const houseLayerList = new Intl.ListFormat("en-GB", { type: "disjunction" }).format(HOUSE_LAYERS);
14
+
15
+ const messages = ruleMessages(ruleName, {
16
+ outside: `Put this rule inside @layer ${houseLayerList}.`,
17
+ undeclared: (name: string) => `'${name}' is not a house layer. Use ${houseLayerList}.`,
18
+ });
19
+
20
+ function isAtRule(node: Node): node is AtRule {
21
+ return node.type === "atrule";
22
+ }
23
+
24
+ function isLayeredOrExempt(styleRule: Rule) {
25
+ for (let parent = styleRule.parent; parent && parent.type !== "root"; parent = parent.parent) {
26
+ if (parent.type === "rule") {
27
+ return true;
28
+ }
29
+ if (isAtRule(parent)) {
30
+ const isTopLevelMedia = parent.name === "media" && parent.parent?.type === "root";
31
+ if (parent.name === "layer" || parent.name.endsWith("keyframes") || isTopLevelMedia) {
32
+ return true;
33
+ }
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+
39
+ const rule: stylelint.Rule = (primary) => (root, result) => {
40
+ if (!validateOptions(result, ruleName, { actual: primary })) {
41
+ return;
42
+ }
43
+
44
+ root.walkAtRules("layer", (atRule) => {
45
+ const name = atRule.params.split(".")[0]?.trim() ?? "";
46
+ const isLayerBlock = atRule.nodes !== undefined;
47
+ if (isLayerBlock && !HOUSE_LAYERS.has(name)) {
48
+ report({ message: messages.undeclared(name), node: atRule, result, ruleName });
49
+ }
50
+ });
51
+
52
+ root.walkRules((styleRule) => {
53
+ if (!isLayeredOrExempt(styleRule)) {
54
+ report({ message: messages.outside, node: styleRule, result, ruleName });
55
+ }
56
+ });
57
+ };
58
+
59
+ rule.ruleName = ruleName;
60
+ rule.messages = messages;
61
+
62
+ export const layered = createPlugin(ruleName, rule);
@@ -0,0 +1,37 @@
1
+ import type { ChildNode } from "postcss";
2
+ import stylelint from "stylelint";
3
+
4
+ const {
5
+ createPlugin,
6
+ utils: { report, ruleMessages, validateOptions },
7
+ } = stylelint;
8
+
9
+ const ruleName = "nks/media-last";
10
+
11
+ const messages = ruleMessages(ruleName, {
12
+ rejected: "Move this above the first top-level @media. Media queries go at the bottom of the file.",
13
+ });
14
+
15
+ function isMedia(node: ChildNode) {
16
+ return node.type === "atrule" && node.name === "media";
17
+ }
18
+
19
+ const rule: stylelint.Rule = (primary) => (root, result) => {
20
+ if (!validateOptions(result, ruleName, { actual: primary })) {
21
+ return;
22
+ }
23
+
24
+ let mediaSeen = false;
25
+ for (const node of root.nodes) {
26
+ if (isMedia(node)) {
27
+ mediaSeen = true;
28
+ } else if (mediaSeen && (node.type === "rule" || node.type === "atrule")) {
29
+ report({ message: messages.rejected, node, result, ruleName });
30
+ }
31
+ }
32
+ };
33
+
34
+ rule.ruleName = ruleName;
35
+ rule.messages = messages;
36
+
37
+ export const mediaLast = createPlugin(ruleName, rule);
@@ -0,0 +1,45 @@
1
+ import stylelint from "stylelint";
2
+
3
+ const {
4
+ createPlugin,
5
+ utils: { report, ruleMessages, validateOptions },
6
+ } = stylelint;
7
+
8
+ const ruleName = "nks/scale-of-three";
9
+
10
+ const messages = ruleMessages(ruleName, {
11
+ rejected: (length: string) => `${length} is off the scale. Use 1px or a multiple of 3px (rem at 16px).`,
12
+ });
13
+
14
+ const LENGTH = /(-?\d*\.?\d+)(px|rem)\b/g;
15
+ const PX_PER_REM = 16;
16
+ const SCALE_STEP_PX = 3;
17
+ const HAIRLINE_PX = 1;
18
+ const ROUNDING_TOLERANCE = 1e-9;
19
+
20
+ function isOnScale(amount: number, unit: string) {
21
+ const px = Math.abs(unit === "rem" ? amount * PX_PER_REM : amount);
22
+ const steps = px / SCALE_STEP_PX;
23
+ const isWholeStep = Math.abs(steps - Math.round(steps)) < ROUNDING_TOLERANCE;
24
+ return px === HAIRLINE_PX || isWholeStep;
25
+ }
26
+
27
+ const rule: stylelint.Rule = (primary) => (root, result) => {
28
+ if (!validateOptions(result, ruleName, { actual: primary })) {
29
+ return;
30
+ }
31
+
32
+ root.walkDecls((decl) => {
33
+ for (const [length, amount, unit] of decl.value.matchAll(LENGTH)) {
34
+ const isOffScale = amount !== undefined && unit !== undefined && !isOnScale(Number(amount), unit);
35
+ if (isOffScale) {
36
+ report({ message: messages.rejected(length), node: decl, result, ruleName, word: length });
37
+ }
38
+ }
39
+ });
40
+ };
41
+
42
+ rule.ruleName = ruleName;
43
+ rule.messages = messages;
44
+
45
+ export const scaleOfThree = createPlugin(ruleName, rule);
@@ -0,0 +1,39 @@
1
+ import stylelint from "stylelint";
2
+
3
+ const {
4
+ createPlugin,
5
+ utils: { report, ruleMessages, validateOptions },
6
+ } = stylelint;
7
+
8
+ const ruleName = "nks/var-fallback";
9
+
10
+ const messages = ruleMessages(ruleName, {
11
+ rejected: (name: string) => `Give var(${name}) a fallback: var(${name}, value).`,
12
+ });
13
+
14
+ const BARE_VAR = /var\(\s*(--[\w-]+)\s*\)/g;
15
+
16
+ const rule: stylelint.Rule = (primary) => (root, result) => {
17
+ if (!validateOptions(result, ruleName, { actual: primary })) {
18
+ return;
19
+ }
20
+
21
+ const declaredInFile = new Set<string>();
22
+ root.walkDecls(/^--/, (decl) => {
23
+ declaredInFile.add(decl.prop);
24
+ });
25
+
26
+ root.walkDecls((decl) => {
27
+ for (const [bareVar, name] of decl.value.matchAll(BARE_VAR)) {
28
+ const needsFallback = name !== undefined && !declaredInFile.has(name);
29
+ if (needsFallback) {
30
+ report({ message: messages.rejected(name), node: decl, result, ruleName, word: bareVar });
31
+ }
32
+ }
33
+ });
34
+ };
35
+
36
+ rule.ruleName = ruleName;
37
+ rule.messages = messages;
38
+
39
+ export const varFallback = createPlugin(ruleName, rule);
@@ -0,0 +1,56 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { Config } from "stylelint";
3
+ import { layered } from "./stylelint/layered.js";
4
+ import { mediaLast } from "./stylelint/mediaLast.js";
5
+ import { scaleOfThree } from "./stylelint/scaleOfThree.js";
6
+ import { varFallback } from "./stylelint/varFallback.js";
7
+
8
+ const orderPlugin = fileURLToPath(import.meta.resolve("stylelint-order"));
9
+
10
+ const BEM_CLASS = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$/;
11
+
12
+ const BRIGHTNESS = /brightness\(/;
13
+
14
+ const disallowedEverywhere = { filter: [BRIGHTNESS] };
15
+
16
+ function disallowedValueMessage(_property: string, value: string) {
17
+ return BRIGHTNESS.test(value)
18
+ ? "Do not use brightness()."
19
+ : "Use a semantic token here. Primitive tokens belong in the token files.";
20
+ }
21
+
22
+ const config: Config = {
23
+ overrides: [
24
+ {
25
+ files: ["**/tokens/**/*.css"],
26
+ rules: {
27
+ "declaration-property-value-disallowed-list": [disallowedEverywhere, { message: disallowedValueMessage }],
28
+ },
29
+ },
30
+ ],
31
+ plugins: [layered, mediaLast, orderPlugin, scaleOfThree, varFallback],
32
+ rules: {
33
+ "color-named": "never",
34
+ "color-no-hex": true,
35
+ "declaration-no-important": true,
36
+ "declaration-property-value-disallowed-list": [
37
+ { ...disallowedEverywhere, "/.*/": [/var\(--(palette|scale)-/] },
38
+ { message: disallowedValueMessage },
39
+ ],
40
+ "function-disallowed-list": [["rgb", "rgba", "hsl", "hsla"], { message: "Use oklch()." }],
41
+ "max-nesting-depth": 3,
42
+ "nks/layered": true,
43
+ "nks/media-last": true,
44
+ "nks/scale-of-three": true,
45
+ "nks/var-fallback": true,
46
+ "order/order": ["custom-properties", "declarations", "rules", "at-rules"],
47
+ "order/properties-alphabetical-order": true,
48
+ "selector-class-pattern": [
49
+ BEM_CLASS,
50
+ { message: "Expected BEM kebab-case: block, block__element, block--modifier" },
51
+ ],
52
+ "unit-allowed-list": ["rem", "px", "%", "fr", "vh", "dvh", "svh", "vw", "deg", "ms", "s"],
53
+ },
54
+ };
55
+
56
+ export default config;