@ncksnydr/config-js 0.2.0 → 0.4.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`:
@@ -110,7 +145,69 @@ To change a setting, merge the preset with your own configuration:
110
145
  import preset from "@ncksnydr/config-js/vitest";
111
146
  import { defineConfig, mergeConfig } from "vitest/config";
112
147
 
113
- export default mergeConfig(preset, defineConfig({ test: { include: ["tests/**/*.test.ts"] } }));
148
+ export default mergeConfig(preset, defineConfig({ test: { testTimeout: 10_000 } }));
149
+ ```
150
+
151
+ The preset runs tests in Node. It restores mocks between tests. If it finds no tests, the run fails.
152
+
153
+ The preset does not set `include`, so Vitest finds test files with its own pattern, such as `*.test.ts` and `*.test.tsx`. To use your own pattern, merge an `include` into the preset.
154
+
155
+ ## React
156
+
157
+ A React Consumer uses the React presets in place of the plain ones. Each React preset holds the plain preset and adds to it.
158
+
159
+ ### TypeScript for React
160
+
161
+ Extend the React base in your `tsconfig.json`:
162
+
163
+ ```json
164
+ {
165
+ "extends": "@ncksnydr/config-js/tsconfig-react"
166
+ }
167
+ ```
168
+
169
+ The React base adds the DOM types. It compiles JSX with React's automatic runtime, so a component file does not import React to use JSX. Install `react` and `@types/react`.
170
+
171
+ ### ESLint for React
172
+
173
+ Use the React configuration as your `eslint.config.js`:
174
+
175
+ ```js
176
+ export { default } from "@ncksnydr/config-js/eslint-react";
177
+ ```
178
+
179
+ The React configuration adds these rules to `.ts` and `.tsx` files:
180
+
181
+ - The recommended rules of `eslint-plugin-react`, `eslint-plugin-react-hooks` and `eslint-plugin-jsx-a11y`.
182
+ - A missing dependency in a hook such as `useEffect` is an error, not a warning.
183
+ - `useContext` is banned. Put shared state in a Zustand store.
184
+ - `fireEvent` from `@testing-library/react` is banned. Use `userEvent`.
185
+ - Text cannot be written inside JSX. Put the text in a variable, and use the variable in the JSX. Text in a prop is allowed.
186
+
187
+ ```text
188
+ Welcome.tsx
189
+ 2:13 error Strings not allowed in JSX files: "Welcome back" react/jsx-no-literals
190
+ ```
191
+
192
+ The configuration reads the React version from the `react` package that your code imports. Use `boundaries()` with it the same way as with the plain configuration.
193
+
194
+ ### Vitest for React
195
+
196
+ Use the React preset as your `vitest.config.ts`:
197
+
198
+ ```ts
199
+ export { default } from "@ncksnydr/config-js/vitest-react";
200
+ ```
201
+
202
+ The React preset holds the plain Vitest preset, and runs the tests in Vitest's browser mode, in Chromium through Playwright. The Storybook Vitest addon uses the same browser.
203
+
204
+ The React preset sets no `include`, so you can add the Storybook Vitest plugin to it. The plugin sets `include` to your stories. It prints a warning when the configuration already has an `include`.
205
+
206
+ Install Playwright and the Vitest Playwright provider. Then install Chromium once on each machine and in CI:
207
+
208
+ ```sh
209
+ pnpm add --save-dev playwright @vitest/browser-playwright
210
+ pnpm exec playwright install chromium
114
211
  ```
115
212
 
116
- The preset runs tests in Node and finds `*.test.ts` files next to the code. It restores mocks between tests. If it finds no tests, the run fails.
213
+ In CI on Linux, use `pnpm exec playwright install --with-deps chromium`. This command also installs the system libraries that Chromium needs.
@@ -0,0 +1,2 @@
1
+ declare const _default: import("eslint/config").ConfigObject[];
2
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { fixupPluginRules } from "@eslint/compat";
2
+ import jsxA11y from "eslint-plugin-jsx-a11y";
3
+ import react from "eslint-plugin-react";
4
+ import reactHooks from "eslint-plugin-react-hooks";
5
+ import { defineConfig } from "eslint/config";
6
+ import base from "./eslint.js";
7
+ const reactWithVersionDetection = fixupPluginRules(react);
8
+ function reactPluginConfig(name) {
9
+ const config = react.configs.flat[name];
10
+ if (config === undefined) {
11
+ throw new Error(`eslint-plugin-react has no flat configuration named "${name}".`);
12
+ }
13
+ return { ...config, plugins: { react: reactWithVersionDetection } };
14
+ }
15
+ export default defineConfig(base, {
16
+ extends: [
17
+ jsxA11y.flatConfigs.recommended,
18
+ reactPluginConfig("recommended"),
19
+ reactPluginConfig("jsx-runtime"),
20
+ reactHooks.configs.flat.recommended,
21
+ ],
22
+ files: ["**/*.{ts,tsx}"],
23
+ rules: {
24
+ "no-restricted-imports": [
25
+ "error",
26
+ {
27
+ paths: [
28
+ { importNames: ["useContext"], message: "Put shared state in a Zustand store.", name: "react" },
29
+ { importNames: ["fireEvent"], message: "Use userEvent.", name: "@testing-library/react" },
30
+ ],
31
+ },
32
+ ],
33
+ "react-hooks/exhaustive-deps": "error",
34
+ "react/jsx-no-literals": ["error", { ignoreProps: true, noStrings: true }],
35
+ },
36
+ settings: {
37
+ react: {
38
+ version: "detect",
39
+ },
40
+ },
41
+ });
42
+ //# sourceMappingURL=eslint-react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eslint-react.js","sourceRoot":"","sources":["../src/eslint-react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,OAAO,MAAM,wBAAwB,CAAC;AAC7C,OAAO,KAAK,MAAM,qBAAqB,CAAC;AACxC,OAAO,UAAU,MAAM,2BAA2B,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,IAAI,MAAM,aAAa,CAAC;AAE/B,MAAM,yBAAyB,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;AAE1D,SAAS,iBAAiB,CAAC,IAAY;IACrC,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,wDAAwD,IAAI,IAAI,CAAC,CAAC;IACpF,CAAC;IACD,OAAO,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,yBAAyB,EAAE,EAAE,CAAC;AACtE,CAAC;AAED,eAAe,YAAY,CAAC,IAAI,EAAE;IAChC,OAAO,EAAE;QACP,OAAO,CAAC,WAAW,CAAC,WAAW;QAC/B,iBAAiB,CAAC,aAAa,CAAC;QAChC,iBAAiB,CAAC,aAAa,CAAC;QAChC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW;KACpC;IACD,KAAK,EAAE,CAAC,eAAe,CAAC;IACxB,KAAK,EAAE;QACL,uBAAuB,EAAE;YACvB,OAAO;YACP;gBACE,KAAK,EAAE;oBACL,EAAE,WAAW,EAAE,CAAC,YAAY,CAAC,EAAE,OAAO,EAAE,sCAAsC,EAAE,IAAI,EAAE,OAAO,EAAE;oBAC/F,EAAE,WAAW,EAAE,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,wBAAwB,EAAE;iBAC1F;aACF;SACF;QACD,6BAA6B,EAAE,OAAO;QACtC,uBAAuB,EAAE,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;KAC3E;IACD,QAAQ,EAAE;QACR,KAAK,EAAE;YACL,OAAO,EAAE,QAAQ;SAClB;KACF;CACF,CAAC,CAAC"}
@@ -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"}
@@ -0,0 +1,3 @@
1
+ import { type ViteUserConfig } from "vitest/config";
2
+ declare const preset: ViteUserConfig;
3
+ export default preset;
@@ -0,0 +1,15 @@
1
+ import { playwright } from "@vitest/browser-playwright";
2
+ import { defineConfig, mergeConfig } from "vitest/config";
3
+ import plain from "./vitest.js";
4
+ const preset = mergeConfig(plain, defineConfig({
5
+ test: {
6
+ browser: {
7
+ enabled: true,
8
+ headless: true,
9
+ instances: [{ browser: "chromium" }],
10
+ provider: playwright(),
11
+ },
12
+ },
13
+ }));
14
+ export default preset;
15
+ //# sourceMappingURL=vitest-react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest-react.js","sourceRoot":"","sources":["../src/vitest-react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAuB,MAAM,eAAe,CAAC;AAC/E,OAAO,KAAK,MAAM,aAAa,CAAC;AAEhC,MAAM,MAAM,GAAmB,WAAW,CACxC,KAAK,EACL,YAAY,CAAC;IACX,IAAI,EAAE;QACJ,OAAO,EAAE;YACP,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;YACd,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;YACpC,QAAQ,EAAE,UAAU,EAAE;SACvB;KACF;CACF,CAAC,CACH,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/vitest.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- declare const _default: import("vite").UserConfig;
2
- export default _default;
1
+ import { type ViteUserConfig } from "vitest/config";
2
+ declare const preset: ViteUserConfig;
3
+ export default preset;
package/dist/vitest.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { defineConfig } from "vitest/config";
2
- export default defineConfig({
2
+ const preset = defineConfig({
3
3
  test: {
4
4
  environment: "node",
5
- include: ["**/*.test.ts"],
6
- restoreMocks: true,
7
5
  passWithNoTests: false,
6
+ restoreMocks: true,
8
7
  },
9
8
  });
9
+ export default preset;
10
10
  //# sourceMappingURL=vitest.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM;QACnB,OAAO,EAAE,CAAC,cAAc,CAAC;QACzB,YAAY,EAAE,IAAI;QAClB,eAAe,EAAE,KAAK;KACvB;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,eAAe,CAAC;AAElE,MAAM,MAAM,GAAmB,YAAY,CAAC;IAC1C,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM;QACnB,eAAe,EAAE,KAAK;QACtB,YAAY,EAAE,IAAI;KACnB;CACF,CAAC,CAAC;AAEH,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.4.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",
@@ -13,14 +13,27 @@
13
13
  "types": "./dist/eslint.d.ts",
14
14
  "default": "./dist/eslint.js"
15
15
  },
16
+ "./eslint-react": {
17
+ "types": "./dist/eslint-react.d.ts",
18
+ "default": "./dist/eslint-react.js"
19
+ },
16
20
  "./prettier": {
17
21
  "types": "./dist/prettier.d.ts",
18
22
  "default": "./dist/prettier.js"
19
23
  },
24
+ "./stylelint": {
25
+ "types": "./dist/stylelint.d.ts",
26
+ "default": "./dist/stylelint.js"
27
+ },
20
28
  "./tsconfig": "./tsconfig/base.json",
29
+ "./tsconfig-react": "./tsconfig/react.json",
21
30
  "./vitest": {
22
31
  "types": "./dist/vitest.d.ts",
23
32
  "default": "./dist/vitest.js"
33
+ },
34
+ "./vitest-react": {
35
+ "types": "./dist/vitest-react.d.ts",
36
+ "default": "./dist/vitest-react.js"
24
37
  }
25
38
  },
26
39
  "files": [
@@ -32,18 +45,30 @@
32
45
  "tsconfig"
33
46
  ],
34
47
  "peerDependencies": {
48
+ "@vitest/browser-playwright": "^5.0.0",
35
49
  "eslint": "^10.0.0",
50
+ "playwright": "^1.65.0",
36
51
  "prettier": "^3.5.0",
52
+ "stylelint": "^18.0.0",
37
53
  "typescript": "^6.0.0",
38
54
  "vitest": "^5.0.0"
39
55
  },
40
56
  "peerDependenciesMeta": {
57
+ "@vitest/browser-playwright": {
58
+ "optional": true
59
+ },
41
60
  "eslint": {
42
61
  "optional": true
43
62
  },
63
+ "playwright": {
64
+ "optional": true
65
+ },
44
66
  "prettier": {
45
67
  "optional": true
46
68
  },
69
+ "stylelint": {
70
+ "optional": true
71
+ },
47
72
  "typescript": {
48
73
  "optional": true
49
74
  },
@@ -52,16 +77,28 @@
52
77
  }
53
78
  },
54
79
  "dependencies": {
80
+ "@eslint/compat": "^2.1.1",
55
81
  "@eslint/js": "^10.0.1",
56
82
  "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
57
83
  "eslint-import-resolver-typescript": "^4.4.5",
58
84
  "eslint-plugin-import-x": "^4.18.1",
85
+ "eslint-plugin-jsx-a11y": "^6.10.2",
86
+ "eslint-plugin-react": "^7.37.5",
87
+ "eslint-plugin-react-hooks": "^7.1.1",
88
+ "stylelint-order": "^8.1.3",
59
89
  "typescript-eslint": "^8.72.2"
60
90
  },
61
91
  "devDependencies": {
92
+ "@types/eslint-plugin-jsx-a11y": "^6.10.1",
62
93
  "@types/node": "^26.6.6",
94
+ "@types/react": "^19.3.0",
95
+ "@vitest/browser-playwright": "^5.1.0",
63
96
  "eslint": "^10.14.0",
97
+ "playwright": "^1.65.0",
98
+ "postcss": "^8.5.32",
64
99
  "prettier": "^3.10.0",
100
+ "react": "^19.3.1",
101
+ "stylelint": "^18.0.0",
65
102
  "typescript": "~6.0.3",
66
103
  "vitest": "^5.1.0"
67
104
  },
@@ -0,0 +1,45 @@
1
+ import { fixupPluginRules } from "@eslint/compat";
2
+ import type { Linter } from "eslint";
3
+ import jsxA11y from "eslint-plugin-jsx-a11y";
4
+ import react from "eslint-plugin-react";
5
+ import reactHooks from "eslint-plugin-react-hooks";
6
+ import { defineConfig } from "eslint/config";
7
+ import base from "./eslint.js";
8
+
9
+ const reactWithVersionDetection = fixupPluginRules(react);
10
+
11
+ function reactPluginConfig(name: string): Linter.Config {
12
+ const config = react.configs.flat[name];
13
+ if (config === undefined) {
14
+ throw new Error(`eslint-plugin-react has no flat configuration named "${name}".`);
15
+ }
16
+ return { ...config, plugins: { react: reactWithVersionDetection } };
17
+ }
18
+
19
+ export default defineConfig(base, {
20
+ extends: [
21
+ jsxA11y.flatConfigs.recommended,
22
+ reactPluginConfig("recommended"),
23
+ reactPluginConfig("jsx-runtime"),
24
+ reactHooks.configs.flat.recommended,
25
+ ],
26
+ files: ["**/*.{ts,tsx}"],
27
+ rules: {
28
+ "no-restricted-imports": [
29
+ "error",
30
+ {
31
+ paths: [
32
+ { importNames: ["useContext"], message: "Put shared state in a Zustand store.", name: "react" },
33
+ { importNames: ["fireEvent"], message: "Use userEvent.", name: "@testing-library/react" },
34
+ ],
35
+ },
36
+ ],
37
+ "react-hooks/exhaustive-deps": "error",
38
+ "react/jsx-no-literals": ["error", { ignoreProps: true, noStrings: true }],
39
+ },
40
+ settings: {
41
+ react: {
42
+ version: "detect",
43
+ },
44
+ },
45
+ });
@@ -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;
@@ -0,0 +1,19 @@
1
+ import { playwright } from "@vitest/browser-playwright";
2
+ import { defineConfig, mergeConfig, type ViteUserConfig } from "vitest/config";
3
+ import plain from "./vitest.js";
4
+
5
+ const preset: ViteUserConfig = mergeConfig(
6
+ plain,
7
+ defineConfig({
8
+ test: {
9
+ browser: {
10
+ enabled: true,
11
+ headless: true,
12
+ instances: [{ browser: "chromium" }],
13
+ provider: playwright(),
14
+ },
15
+ },
16
+ }),
17
+ );
18
+
19
+ export default preset;
package/src/vitest.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { defineConfig } from "vitest/config";
1
+ import { defineConfig, type ViteUserConfig } from "vitest/config";
2
2
 
3
- export default defineConfig({
3
+ const preset: ViteUserConfig = defineConfig({
4
4
  test: {
5
5
  environment: "node",
6
- include: ["**/*.test.ts"],
7
- restoreMocks: true,
8
6
  passWithNoTests: false,
7
+ restoreMocks: true,
9
8
  },
10
9
  });
10
+
11
+ export default preset;
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "./base.json",
4
+ "compilerOptions": {
5
+ "jsx": "react-jsx",
6
+ "lib": ["DOM", "DOM.Iterable", "ES2024"]
7
+ }
8
+ }