@mslmio/eslint-config 1.1.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/README.md CHANGED
@@ -28,7 +28,7 @@ UI half behind `/ui` means the root entry resolves neither `@shadcn/lint` nor
28
28
  |---|---|---|
29
29
  | `house` | anything TS/JS | `no-property-shorthand`, `curly: all`, `us-spelling`, `no-express-json-spaces` |
30
30
  | `lib` | a published library | house + `no-lib-process-env`, `no-module-scope-init` |
31
- | `ui` | anything rendering Tailwind | house + `require-leading-with-text-size`, `prefer-copy-icon` |
31
+ | `ui` | anything rendering Tailwind | house + `require-leading-with-text-size`, `prefer-copy-icon`, `prefer-table-facets` |
32
32
  | `uiLib` | a published UI library | `lib` + `ui` |
33
33
 
34
34
  ## Rules
@@ -43,6 +43,7 @@ UI half behind `/ui` means the root entry resolves neither `@shadcn/lint` nor
43
43
  | `prefer-copy-icon` | lucide `Copy`, not `Clipboard` |
44
44
  | `us-spelling` | US spelling in prose and identifiers |
45
45
  | `require-leading-with-text-size` | An arbitrary Tailwind text size must state its own line height |
46
+ | `prefer-table-facets` | A `kind: "select"` filter lists the values its rows hold (facets), never a literal `options` list |
46
47
 
47
48
  `us-spelling` skips regex literals — a pattern matching scraped text has to accept both
48
49
  spellings — and takes `allow` for proper names that keep their own:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mslmio/eslint-config",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shareable ESLint flat config and house rules: US spelling, library hygiene, workspace layering, and Tailwind/React conventions.",
5
5
  "keywords": [
6
6
  "eslint",
package/plugin.js CHANGED
@@ -6,9 +6,10 @@ import usSpelling from "./rules/us-spelling.js";
6
6
  import requireLeadingWithTextSize from "./rules/require-leading-with-text-size.js";
7
7
  import noPropertyShorthand from "./rules/no-property-shorthand.js";
8
8
  import noUpwardImport from "./rules/no-upward-import.js";
9
+ import preferTableFacets from "./rules/prefer-table-facets.js";
9
10
 
10
11
  export default {
11
- meta: { name: "@mslmio/eslint-plugin", version: "2.4.0" },
12
+ meta: { name: "@mslmio/eslint-plugin", version: "2.5.0" },
12
13
  rules: {
13
14
  "no-lib-process-env": noLibProcessEnv,
14
15
  "no-module-scope-init": noModuleScopeInit,
@@ -18,5 +19,6 @@ export default {
18
19
  "require-leading-with-text-size": requireLeadingWithTextSize,
19
20
  "no-property-shorthand": noPropertyShorthand,
20
21
  "no-upward-import": noUpwardImport,
22
+ "prefer-table-facets": preferTableFacets,
21
23
  },
22
24
  };
@@ -0,0 +1,69 @@
1
+ // A table's picklist filter offers the values its rows actually hold - served by
2
+ // the backend as FACETS - rather than a literal list. A literal list is a second
3
+ // copy of a vocabulary the backend owns, and the next value it starts writing is
4
+ // missing from the picklist until someone remembers to add it.
5
+ //
6
+ // Reported: `options` on an object with `kind: "select"` that is an array
7
+ // literal, a `.map` over one, or a map over Object.entries/keys/values - the
8
+ // shapes a hardcoded list takes. Options mapped from runtime data pass. A fixed
9
+ // product vocabulary, one no row can grow, stays literal behind a disable
10
+ // comment saying so.
11
+ export default {
12
+ meta: {
13
+ type: "suggestion",
14
+ docs: {
15
+ description: "A picklist filter lists the values its rows hold (facets), not a literal list",
16
+ },
17
+ schema: [{ type: "object", properties: { docsUrl: { type: "string" } }, additionalProperties: false }],
18
+ messages: {
19
+ literal:
20
+ "A picklist filter should list the values its rows hold: leave `options` off and serve " +
21
+ "them as facets. A fixed product vocabulary stays literal behind a disable comment " +
22
+ "saying why.{{docs}}",
23
+ },
24
+ },
25
+ create(context) {
26
+ const docs = docsSuffix(context);
27
+ return {
28
+ ObjectExpression(node) {
29
+ const kind = property(node, "kind");
30
+ const options = property(node, "options");
31
+ if (!kind || !options || kind.value.type !== "Literal" || kind.value.value !== "select") {
32
+ return;
33
+ }
34
+ if (isLiteralList(options.value)) {
35
+ context.report({ node: options, messageId: "literal", data: { docs: docs } });
36
+ }
37
+ },
38
+ };
39
+ },
40
+ };
41
+
42
+ function property(node, name) {
43
+ return node.properties.find((p) => p.type === "Property" && !p.computed
44
+ && ((p.key.type === "Identifier" && p.key.name === name)
45
+ || (p.key.type === "Literal" && p.key.value === name)));
46
+ }
47
+
48
+ function isLiteralList(v) {
49
+ if (v.type === "ArrayExpression") {
50
+ return true;
51
+ }
52
+ if (v.type !== "CallExpression" || v.callee.type !== "MemberExpression" || v.callee.computed
53
+ || v.callee.property.name !== "map") {
54
+ return false;
55
+ }
56
+ const source = v.callee.object;
57
+ if (source.type === "ArrayExpression") {
58
+ return true;
59
+ }
60
+ return source.type === "CallExpression" && source.callee.type === "MemberExpression"
61
+ && source.callee.object.type === "Identifier" && source.callee.object.name === "Object"
62
+ && ["entries", "keys", "values"].includes(source.callee.property.name);
63
+ }
64
+
65
+ // A link only an internal caller can resolve, so the public default is none.
66
+ function docsSuffix(context) {
67
+ const url = context.options[0]?.docsUrl;
68
+ return url ? ` See ${url}.` : "";
69
+ }
@@ -1,7 +1,11 @@
1
1
  const CLASS_FNS = new Set(["cn", "clsx", "cva", "classNames", "twMerge", "twJoin"]);
2
2
  const COLOR_RE = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|color-mix\(|currentColor|transparent)/i;
3
- const LENGTH_RE = /(^|[\d.])(rem|px|em|ex|ch|vw|vh|vmin|vmax|pt|pc|%)\b|^(calc|clamp|min|max|var)\(|^[\d.]+$/;
4
- const TEXT_ARB_RE = /\btext-\[([^\]]+)\](\/\S+)?/g;
3
+ const LENGTH_RE = /(^|[\d.])(rem|px|em|ex|ch|vw|vh|vmin|vmax|pt|pc|%)\b|^(calc|clamp|min|max)\(|^[\d.]+$/;
4
+ // The type hints Tailwind resolves `text-` to a font size for. Any other hint names a color.
5
+ const SIZE_HINT_RE = /^(length|percentage|absolute-size|relative-size):/;
6
+ const HINT_RE = /^[a-z-]+:/;
7
+ // `text-[...]`, or the `text-(...)` variable shorthand, with an optional `/leading` modifier.
8
+ const TEXT_ARB_RE = /\btext-(?:\[([^\]]+)\]|\(([^)]+)\))(\/\S+)?/g;
5
9
 
6
10
  export default {
7
11
  meta: {
@@ -13,9 +17,9 @@ export default {
13
17
  schema: [{ type: "object", properties: { docsUrl: { type: "string" } }, additionalProperties: false }],
14
18
  messages: {
15
19
  needsLeading:
16
- "`text-[{{value}}]` sets a font SIZE only, so line height comes from whatever ancestor " +
20
+ "`{{value}}` sets a font SIZE only, so line height comes from whatever ancestor " +
17
21
  "set one - which differs between hosts and renders the same row at two heights. State it: " +
18
- "`text-[{{value}}]/[<leading>]` or an explicit `leading-*`.{{docs}}",
22
+ "`{{value}}/[<leading>]` or an explicit `leading-*`.{{docs}}",
19
23
  },
20
24
  },
21
25
  create(context) {
@@ -29,10 +33,10 @@ export default {
29
33
  }
30
34
  for (const part of parts) {
31
35
  for (const m of part.matchAll(TEXT_ARB_RE)) {
32
- if (m[2] || !isLength(m[1])) {
36
+ if (m[3] || !isLength(m[1] ?? m[2])) {
33
37
  continue;
34
38
  }
35
- context.report({ node: node, messageId: "needsLeading", data: { value: m[1], docs: docs } });
39
+ context.report({ node: node, messageId: "needsLeading", data: { value: m[0], docs: docs } });
36
40
  }
37
41
  }
38
42
  };
@@ -74,7 +78,13 @@ function collectStrings(node, seen) {
74
78
 
75
79
  function isLength(value) {
76
80
  const v = value.trim();
77
- if (COLOR_RE.test(v)) {
81
+ if (SIZE_HINT_RE.test(v)) {
82
+ return true;
83
+ }
84
+ // An unhinted variable is a COLOR: Tailwind reads `text-[var(--chart-2)]` and `text-(--chart-2)`
85
+ // as `color`. Calling it a size once got it "fixed" to `text-[var(--chart-2)]/[1.5]`, which
86
+ // compiles to a 150% color-mix - invalid, so the text silently lost its color.
87
+ if (HINT_RE.test(v) || v.startsWith("var(") || v.startsWith("--") || COLOR_RE.test(v)) {
78
88
  return false;
79
89
  }
80
90
  return LENGTH_RE.test(v);
package/ui.js CHANGED
@@ -53,6 +53,7 @@ export const ui = [
53
53
  rules: {
54
54
  "mslmio/require-leading-with-text-size": "error",
55
55
  "mslmio/prefer-copy-icon": "error",
56
+ "mslmio/prefer-table-facets": "error",
56
57
  },
57
58
  },
58
59
  ];