@instruments/materia-ui 0.4.1 → 0.4.3

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.
Files changed (118) hide show
  1. package/README.md +2 -4
  2. package/agent-docs.md +9 -9
  3. package/dist/components/accordion/accordion-root.d.ts +1 -0
  4. package/dist/components/accordion/accordion-root.js +1 -0
  5. package/dist/components/alert/alert-root.d.ts +1 -0
  6. package/dist/components/alert/alert-root.js +1 -0
  7. package/dist/components/alert-dialog/alert-dialog-root.d.ts +1 -0
  8. package/dist/components/alert-dialog/alert-dialog-root.js +1 -0
  9. package/dist/components/avatar/avatar-root.d.ts +1 -0
  10. package/dist/components/avatar/avatar-root.js +1 -0
  11. package/dist/components/badge/badge-root.d.ts +1 -0
  12. package/dist/components/badge/badge-root.js +1 -0
  13. package/dist/components/calendar/calendar-root.js +1 -0
  14. package/dist/components/carousel/carousel-root.d.ts +1 -0
  15. package/dist/components/carousel/carousel-root.js +1 -0
  16. package/dist/components/chart/chart-root.d.ts +1 -0
  17. package/dist/components/chart/chart-root.js +1 -0
  18. package/dist/components/collapsible/collapsible-root.d.ts +1 -0
  19. package/dist/components/collapsible/collapsible-root.js +1 -0
  20. package/dist/components/combobox/combobox-root.d.ts +1 -0
  21. package/dist/components/combobox/combobox-root.js +1 -0
  22. package/dist/components/command/command-root.d.ts +1 -0
  23. package/dist/components/command/command-root.js +1 -0
  24. package/dist/components/dialog/dialog-content.d.ts +2 -0
  25. package/dist/components/dialog/dialog-content.js +3 -2
  26. package/dist/components/dialog/dialog-footer.d.ts +2 -0
  27. package/dist/components/dialog/dialog-footer.js +8 -8
  28. package/dist/components/dialog/dialog-root.d.ts +1 -0
  29. package/dist/components/dialog/dialog-root.js +1 -0
  30. package/dist/components/dialog/dialog-title.d.ts +2 -0
  31. package/dist/components/dialog/dialog-title.js +3 -1
  32. package/dist/components/drawer/drawer-root.d.ts +1 -0
  33. package/dist/components/drawer/drawer-root.js +1 -0
  34. package/dist/components/heading/heading-root.d.ts +1 -0
  35. package/dist/components/heading/heading-root.js +1 -0
  36. package/dist/components/input/input-root.d.ts +1 -0
  37. package/dist/components/input/input-root.js +1 -0
  38. package/dist/components/kbd/kbd-root.d.ts +1 -0
  39. package/dist/components/kbd/kbd-root.js +1 -0
  40. package/dist/components/logo/logo-mark.d.ts +1 -1
  41. package/dist/components/logo/logo-mark.js +1 -1
  42. package/dist/components/logotype/logotype-paths.d.ts +1 -1
  43. package/dist/components/logotype/logotype-paths.js +1 -1
  44. package/dist/components/month-year-picker/month-year-picker-root.d.ts +1 -0
  45. package/dist/components/month-year-picker/month-year-picker-root.js +1 -0
  46. package/dist/components/native-select/native-select-root.d.ts +1 -0
  47. package/dist/components/native-select/native-select-root.js +1 -0
  48. package/dist/components/pill/pill-root.d.ts +1 -0
  49. package/dist/components/pill/pill-root.js +1 -0
  50. package/dist/components/popover/popover-root.d.ts +1 -0
  51. package/dist/components/popover/popover-root.js +1 -0
  52. package/dist/components/quantity-control/quantity-control-root.d.ts +1 -0
  53. package/dist/components/quantity-control/quantity-control-root.js +1 -0
  54. package/dist/components/select/select-root.d.ts +1 -0
  55. package/dist/components/select/select-root.js +1 -0
  56. package/dist/components/separator/separator-root.d.ts +1 -0
  57. package/dist/components/separator/separator-root.js +1 -0
  58. package/dist/components/sheet/sheet-root.d.ts +1 -0
  59. package/dist/components/sheet/sheet-root.js +1 -0
  60. package/dist/components/spinner/spinner-root.d.ts +1 -0
  61. package/dist/components/spinner/spinner-root.js +1 -0
  62. package/dist/components/stepper/stepper-root.d.ts +1 -0
  63. package/dist/components/stepper/stepper-root.js +1 -0
  64. package/dist/components/success-tick-ripple/success-tick-ripple-root.d.ts +1 -0
  65. package/dist/components/success-tick-ripple/success-tick-ripple-root.js +1 -0
  66. package/dist/components/switch/switch-root.d.ts +1 -0
  67. package/dist/components/switch/switch-root.js +1 -0
  68. package/dist/components/tag/tag-root.d.ts +1 -0
  69. package/dist/components/tag/tag-root.js +1 -0
  70. package/dist/components/textarea/textarea-root.d.ts +1 -0
  71. package/dist/components/textarea/textarea-root.js +1 -0
  72. package/dist/components/time-slot-picker/time-slot-picker-root.d.ts +1 -0
  73. package/dist/components/time-slot-picker/time-slot-picker-root.js +1 -0
  74. package/dist/components/toast/toast-root.d.ts +1 -0
  75. package/dist/components/toast/toast-root.js +1 -0
  76. package/dist/components/tooltip/tooltip-root.d.ts +1 -0
  77. package/dist/components/tooltip/tooltip-root.js +1 -0
  78. package/dist/compositions/empty-state/empty-state-root.d.ts +1 -0
  79. package/dist/compositions/empty-state/empty-state-root.js +1 -0
  80. package/dist/compositions/file-upload/file-upload-dropzone.d.ts +1 -0
  81. package/dist/compositions/file-upload/file-upload-dropzone.js +1 -0
  82. package/dist/compositions/product-card/product-card-caption.js +4 -2
  83. package/dist/compositions/product-card/product-card-content-types.d.ts +8 -0
  84. package/dist/compositions/product-card/product-card-meta.js +2 -2
  85. package/dist/compositions/product-card/product-card-provider-types.d.ts +3 -0
  86. package/dist/compositions/product-card/product-card-provider.js +4 -1
  87. package/dist/compositions/product-card/product-card-root.d.ts +1 -1
  88. package/dist/compositions/product-card/product-card-root.js +2 -1
  89. package/dist/compositions/product-card/product-card-skeleton.d.ts +14 -5
  90. package/dist/compositions/product-card/product-card-skeleton.js +6 -5
  91. package/dist/data/decision-trees.d.ts +1 -1
  92. package/dist/data/decision-trees.js +1 -1
  93. package/dist/data/docs-manifest.d.ts +2 -0
  94. package/dist/data/docs-manifest.js +257 -67
  95. package/dist/styles/index.css +0 -1
  96. package/dist/utils/cn-theme.js +1 -1
  97. package/lint/README.md +6 -2
  98. package/lint/doctrine-rules.mjs +4 -4
  99. package/lint/oxlint/index.d.mts +13 -5
  100. package/lint/oxlint/index.mjs +26 -0
  101. package/lint/oxlint/no-naked-element.mjs +123 -65
  102. package/lint/oxlint/no-render-layout-as.mjs +61 -56
  103. package/lint/scan-classes.d.mts +58 -5
  104. package/lint/scan-classes.mjs +274 -38
  105. package/lint/tokens.d.mts +16 -8
  106. package/lint/tokens.mjs +13 -3
  107. package/lint/type-case.d.mts +16 -8
  108. package/lint/type-case.mjs +17 -0
  109. package/package.json +27 -14
  110. package/lint/oxlint/fixtures/allowed.tsx +0 -12
  111. package/lint/oxlint/fixtures/fixture.config.mjs +0 -29
  112. package/lint/oxlint/fixtures/invalid.tsx +0 -24
  113. package/lint/oxlint/fixtures/layout-only.tsx +0 -26
  114. package/lint/oxlint/fixtures/render-as-invalid.tsx +0 -11
  115. package/lint/oxlint/fixtures/render-as-valid.tsx +0 -14
  116. package/lint/oxlint/fixtures/valid.tsx +0 -18
  117. package/lint/oxlint/no-naked-element.test.ts +0 -155
  118. package/lint/oxlint/no-render-layout-as.test.ts +0 -81
@@ -13,7 +13,6 @@
13
13
  @import "./fonts.css";
14
14
 
15
15
  @plugin "tailwindcss-animate";
16
- @plugin "@tailwindcss/typography";
17
16
 
18
17
  @custom-variant dark (&:is(.dark *));
19
18
 
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/generate-cn-theme.mjs - do not edit.
1
+ // GENERATED by scripts/generate-cn-theme.ts - do not edit.
2
2
  // Regenerate with `pnpm generate:cn-theme` after changing index.css; the
3
3
  // cn test fails when a token or utility there no longer merges.
4
4
  /** The house tokens and utilities `cn` resolves conflicts between. */
package/lint/README.md CHANGED
@@ -11,7 +11,7 @@ Guardrails that keep app code inside the design system. The four rules below are
11
11
 
12
12
  The import restrictions are Oxlint config fragments because every unit in this repo lints with `@howells/lint` (oxlint). The hex and arbitrary-value rules scan class strings, which no import-level linter can see, so they run as a small Node script.
13
13
 
14
- The two fragments live at the repo root rather than in this package: they are repo-local policy, extended by each unit's own `oxlint.config.ts`, and each carries the `@doctrine` sentence and the published ban message that `lint/doctrine-rules.mjs` and `scripts/generate-docs-manifest.mjs` hydrate the doctrine page from.
14
+ The two fragments live at the repo root rather than in this package: they are repo-local policy, extended by each unit's own `oxlint.config.ts`, and each carries the `@doctrine` sentence and the published ban message that `lint/doctrine-rules.mjs` and `scripts/generate-docs-manifest.ts` hydrate the doctrine page from.
15
15
 
16
16
  ## Rollout - enable per rule, not all-or-nothing
17
17
 
@@ -112,5 +112,9 @@ Run the scanner as part of your lint script. It is exposed as the `materia-ui-li
112
112
  | `--all` | enable both rules |
113
113
  | `--no-hex` | disable the hex rule |
114
114
  | `--exclude <substr>` | skip any file whose path contains `<substr>` (repeatable) |
115
+ | `--json` | print one JSON document, `{ violations, rules, files }`, instead of text lines |
116
+ | `--help` | print usage and exit |
115
117
 
116
- The scanner scopes detection to class strings only: `class`/`className` attribute values and the argument lists of `cn` / `clsx` / `cva` / `tv` / `twMerge` / `twJoin` / `classNames`. Inline `style={{ … }}` colours and ordinary colour constants are not flagged. It exits non-zero and prints `file:line:col rule value message` for each violation.
118
+ The scanner scopes detection to class strings only: `class`/`className` attribute values and the argument lists of `cn` / `clsx` / `cva` / `tv` / `twMerge` / `twJoin` / `classNames`. Inline `style={{ … }}` colours and ordinary colour constants are not flagged. It prints `file:line:col rule value message` for each violation.
119
+
120
+ Exit codes: `0` clean, `1` violations found, `2` usage error (unknown flag, no paths), `3` a path could not be read.
@@ -6,7 +6,7 @@
6
6
  * layer matrix, one api-drift gate) with NO single source file. This module is
7
7
  * the one place that names them as a coherent set. It carries only the stable
8
8
  * editorial framing (id / name / one-line statement / which gates enforce it);
9
- * `scripts/generate-docs-manifest.mjs` HYDRATES each rule at build time with the
9
+ * `scripts/generate-docs-manifest.ts` HYDRATES each rule at build time with the
10
10
  * live data it must never restate by hand:
11
11
  *
12
12
  * - `restrictedImports` - the import bans read straight out of the repo's
@@ -14,7 +14,7 @@
14
14
  * `{ group, message }` (see `IMPORT_BAN_SOURCES`). A rule opts in via
15
15
  * `banMatch`, a substring matched against those live messages.
16
16
  * - `matrix` / `layers` - the layer dependency matrix imported from
17
- * scripts/layer-matrix.mjs's exported `LAYERS` / `ALLOWED`.
17
+ * scripts/layer-matrix.ts's exported `LAYERS` / `ALLOWED`.
18
18
  * - per-gate `doctrine` prose - the single `@doctrine` sentinel line in each
19
19
  * gate script's header, extracted by a strict tag-line parse.
20
20
  *
@@ -92,7 +92,7 @@ export const DOCTRINE_RULES = [
92
92
  kind: "layer-matrix",
93
93
  statement:
94
94
  "The six src layers form a strict one-directional dependency matrix (data ← tokens ← utils ← hooks ← components ← compositions); tokens additionally take no external packages.",
95
- gates: ["packages/ui/scripts/layer-matrix.mjs"],
95
+ gates: ["packages/ui/scripts/layer-matrix.ts"],
96
96
  },
97
97
  {
98
98
  id: "no-naked-element",
@@ -106,6 +106,6 @@ export const DOCTRINE_RULES = [
106
106
  name: "API surface drift gate",
107
107
  statement:
108
108
  "The full public export surface is captured as resolved-type hashes in api-report.md; any change to a public signature flips a hash and fails the gate until the report is regenerated and reviewed.",
109
- gates: ["packages/ui/scripts/generate-api-report.mjs"],
109
+ gates: ["packages/ui/scripts/generate-api-report.ts"],
110
110
  },
111
111
  ];
@@ -1,12 +1,19 @@
1
- /** One rule of the plugin: its metadata and the visitor factory oxlint calls. */
1
+ // GENERATED by scripts/generate-lint-types.ts from the JSDoc in lint/oxlint/index.mjs - do not edit.
2
+ // Regenerate with `pnpm --filter @instruments/materia-ui generate:lint-types`.
3
+
4
+ /**
5
+ * One rule of the plugin: its metadata and the visitor factory oxlint calls.
6
+ */
2
7
  export interface MateriaRule {
3
8
  readonly meta: {
4
9
  readonly type: "problem";
5
- readonly docs: { readonly description: string };
10
+ readonly docs: {
11
+ readonly description: string;
12
+ };
6
13
  readonly messages: Readonly<Record<string, string>>;
7
14
  readonly schema: readonly unknown[];
8
15
  };
9
- readonly create: (context: unknown) => Record<string, unknown>;
16
+ readonly create: (context: never) => object;
10
17
  }
11
18
 
12
19
  /**
@@ -14,11 +21,12 @@ export interface MateriaRule {
14
21
  * rules as `materia-ui/no-naked-element` and `materia-ui/no-render-layout-as`.
15
22
  */
16
23
  declare const plugin: {
17
- readonly meta: { readonly name: "materia-ui" };
24
+ readonly meta: {
25
+ readonly name: "materia-ui";
26
+ };
18
27
  readonly rules: {
19
28
  readonly "no-naked-element": MateriaRule;
20
29
  readonly "no-render-layout-as": MateriaRule;
21
30
  };
22
31
  };
23
-
24
32
  export default plugin;
@@ -1,6 +1,32 @@
1
1
  import { rule as noNakedElement } from "./no-naked-element.mjs";
2
2
  import { rule as noRenderLayoutAs } from "./no-render-layout-as.mjs";
3
3
 
4
+ /**
5
+ * One rule of the plugin: its metadata and the visitor factory oxlint calls.
6
+ *
7
+ * @typedef {{
8
+ * readonly meta: {
9
+ * readonly type: "problem";
10
+ * readonly docs: { readonly description: string };
11
+ * readonly messages: Readonly<Record<string, string>>;
12
+ * readonly schema: readonly unknown[];
13
+ * };
14
+ * readonly create: (context: never) => object;
15
+ * }} MateriaRule
16
+ */
17
+
18
+ /**
19
+ * The `materia-ui` oxlint plugin. Load it through `jsPlugins` and enable its
20
+ * rules as `materia-ui/no-naked-element` and `materia-ui/no-render-layout-as`.
21
+ *
22
+ * @type {{
23
+ * readonly meta: { readonly name: "materia-ui" };
24
+ * readonly rules: {
25
+ * readonly "no-naked-element": MateriaRule;
26
+ * readonly "no-render-layout-as": MateriaRule;
27
+ * };
28
+ * }}
29
+ */
4
30
  const plugin = {
5
31
  meta: {
6
32
  name: "materia-ui",
@@ -10,6 +10,20 @@
10
10
  //
11
11
  // @doctrine Application markup renders only design-system components - every raw lowercase HTML host element is lint-banned, leaving the closed polymorphic `as` vocabulary and a tokens-policed className as the only routes to the metal.
12
12
 
13
+ /** @typedef {Parameters<import("oxlint/plugins-dev").RuleTester["run"]>[1]} Rule */
14
+ /** @typedef {NonNullable<Rule["create"]>} Create */
15
+ /** @typedef {Parameters<Create>[0]} Context */
16
+ /** @typedef {ReturnType<Create>} Visitor */
17
+ /** @typedef {Parameters<NonNullable<Visitor["JSXOpeningElement"]>>[0]} OpeningElement */
18
+ /** @typedef {Extract<OpeningElement["attributes"][number], { type: "JSXAttribute" }>} Attribute */
19
+ /** @typedef {NonNullable<Attribute["value"]>} AttributeValue */
20
+ /** @typedef {Extract<AttributeValue, { type: "JSXExpressionContainer" }>["expression"]} Expression */
21
+ /** @typedef {Extract<Expression, { type: "CallExpression" }>["arguments"][number]} Argument */
22
+ /** @typedef {Extract<Expression, { type: "TemplateLiteral" }>} TemplateLiteral */
23
+ /** @typedef {Extract<Expression, { type: "Literal"; value: string }>} StringLiteral */
24
+ /** @typedef {Context["options"][number]} OptionValue */
25
+ /** @typedef {Extract<OptionValue, Record<string, OptionValue>>} OptionObject */
26
+
13
27
  /**
14
28
  * Map of raw HTML tag → the design-system replacement guidance surfaced in the
15
29
  * diagnostic. Every member of the tokens `LAYOUT_ELEMENTS` / `TEXT_ELEMENTS`
@@ -67,14 +81,16 @@ const ESCAPE_HATCH =
67
81
  * Build the diagnostic message for a banned raw tag.
68
82
  *
69
83
  * @param {string} tag lowercase HTML tag name
70
- * @returns {string}
84
+ * @returns {string} The message naming the sanctioned replacement.
71
85
  */
72
- export function messageFor(tag) {
73
- const guidance = REPLACEMENTS[tag]
74
- ? `Use ${REPLACEMENTS[tag]} instead.`
75
- : "Use a @instruments/materia-ui component instead.";
86
+ export const messageFor = (tag) => {
87
+ const replacement = REPLACEMENTS[tag];
88
+ const guidance =
89
+ replacement === undefined
90
+ ? "Use a @instruments/materia-ui component instead."
91
+ : `Use ${replacement} instead.`;
76
92
  return `Raw <${tag}> is banned in the LLM-safe design system. ${guidance} ${ESCAPE_HATCH}`;
77
- }
93
+ };
78
94
 
79
95
  /**
80
96
  * Hosts a layout primitive can emit through `as` - the tokens `LAYOUT_ELEMENTS`
@@ -110,26 +126,27 @@ const LAYOUT_UTILITY_SET = new Set(LAYOUT_UTILITIES);
110
126
  *
111
127
  * @param {string} tag lowercase HTML tag name
112
128
  * @param {string} utility the layout utility found in its classes
113
- * @returns {string}
129
+ * @returns {string} The message naming the layout primitive to use.
114
130
  */
115
- export function layoutMessageFor(tag, utility) {
131
+ export const layoutMessageFor = (tag, utility) => {
116
132
  const primitive = utility.endsWith("grid")
117
133
  ? "Grid | SimpleGrid"
118
134
  : "Flex | Stack | Group | Center";
119
135
  const as = tag === "div" ? "" : ` with as="${tag}"`;
120
136
  return `Raw <${tag}> with \`${utility}\` is a hand-rolled layout. Use ${primitive}${as} instead. ${ESCAPE_HATCH}`;
121
- }
137
+ };
122
138
 
123
139
  /**
124
140
  * Whole class tokens of one template literal, dropping any token glued to an
125
141
  * interpolation (`size-${n}`), since only its static half is known.
126
142
  *
127
- * @param {{ quasis: Array<{ value: { cooked?: string, raw: string } }> }} node
128
- * @returns {string[]}
143
+ * @param {TemplateLiteral} node The template literal.
144
+ * @returns {string[]} The class tokens whose full text is known.
129
145
  */
130
- function templateTokens(node) {
146
+ const templateTokens = (node) => {
147
+ /** @type {string[]} */
131
148
  const tokens = [];
132
- node.quasis.forEach((quasi, index) => {
149
+ for (const [index, quasi] of node.quasis.entries()) {
133
150
  const text = quasi.value.cooked ?? quasi.value.raw;
134
151
  const parts = text.split(/\s+/u);
135
152
  if (index > 0 && !/^\s/u.test(text)) {
@@ -139,9 +156,16 @@ function templateTokens(node) {
139
156
  parts.pop();
140
157
  }
141
158
  tokens.push(...parts);
142
- });
159
+ }
143
160
  return tokens;
144
- }
161
+ };
162
+
163
+ /**
164
+ * @param {Expression | Argument | AttributeValue} node A node that may be a literal.
165
+ * @returns {node is StringLiteral} Whether it is a string literal.
166
+ */
167
+ const isStringLiteral = (node) =>
168
+ node.type === "Literal" && node.value?.constructor === String;
145
169
 
146
170
  /**
147
171
  * Class tokens an expression always contributes: string and template literals,
@@ -149,15 +173,15 @@ function templateTokens(node) {
149
173
  * Conditional branches are skipped - a host that is only sometimes a flex
150
174
  * container is not a fixed layout.
151
175
  *
152
- * @param {any} node
153
- * @param {boolean} [inCall]
154
- * @returns {string[]}
176
+ * @param {Expression | Argument | AttributeValue | null | undefined} node The class expression.
177
+ * @param {boolean} [inCall] Whether `node` is already an argument of a helper call.
178
+ * @returns {string[]} The class tokens that always apply.
155
179
  */
156
- function unconditionalTokens(node, inCall = false) {
180
+ const unconditionalTokens = (node, inCall = false) => {
157
181
  if (!node) {
158
182
  return [];
159
183
  }
160
- if (node.type === "Literal" && typeof node.value === "string") {
184
+ if (isStringLiteral(node)) {
161
185
  return node.value.split(/\s+/u);
162
186
  }
163
187
  if (node.type === "TemplateLiteral") {
@@ -167,31 +191,63 @@ function unconditionalTokens(node, inCall = false) {
167
191
  return node.arguments.flatMap((arg) => unconditionalTokens(arg, true));
168
192
  }
169
193
  return [];
170
- }
194
+ };
195
+
196
+ /**
197
+ * @param {OpeningElement["attributes"][number]} attribute A JSX attribute.
198
+ * @returns {attribute is Attribute} Whether it is `className`.
199
+ */
200
+ const isClassName = (attribute) =>
201
+ attribute.type === "JSXAttribute" &&
202
+ attribute.name.type === "JSXIdentifier" &&
203
+ attribute.name.name === "className";
171
204
 
172
205
  /**
173
206
  * The first layout utility among a JSX element's unconditional classes.
174
207
  *
175
- * @param {any} openingElement
176
- * @returns {string | undefined}
208
+ * @param {OpeningElement} openingElement The element to inspect.
209
+ * @returns {string | undefined} The utility, if there is one.
177
210
  */
178
- function layoutUtilityOf(openingElement) {
179
- for (const attribute of openingElement.attributes) {
180
- if (
181
- attribute.type !== "JSXAttribute" ||
182
- attribute.name?.name !== "className"
183
- ) {
184
- continue;
185
- }
186
- const { value } = attribute;
187
- const expression =
188
- value?.type === "JSXExpressionContainer" ? value.expression : value;
189
- return unconditionalTokens(expression).find((token) =>
190
- LAYOUT_UTILITY_SET.has(token)
191
- );
211
+ const layoutUtilityOf = (openingElement) => {
212
+ const value = openingElement.attributes.find(isClassName)?.value;
213
+ const expression =
214
+ value?.type === "JSXExpressionContainer" ? value.expression : value;
215
+ return unconditionalTokens(expression).find((token) =>
216
+ LAYOUT_UTILITY_SET.has(token)
217
+ );
218
+ };
219
+
220
+ /**
221
+ * @param {OptionValue | undefined} value A rule option.
222
+ * @returns {value is OptionObject} Whether it is an options object.
223
+ */
224
+ const isOptionObject = (value) =>
225
+ value instanceof Object && !Array.isArray(value);
226
+
227
+ /**
228
+ * @param {OptionValue | undefined} value An entry of the `allow` option.
229
+ * @returns {value is string} Whether it is a tag name.
230
+ */
231
+ const isTagName = (value) => value?.constructor === String;
232
+
233
+ /**
234
+ * The rule's options as configured: `allow` names exempt tags and `layoutOnly`
235
+ * narrows the rule to hand-rolled layout.
236
+ *
237
+ * @param {Context} context The rule context.
238
+ * @returns {{ allow: Set<string>, layoutOnly: boolean }} The options with defaults applied.
239
+ */
240
+ const readOptions = (context) => {
241
+ const [option] = context.options;
242
+ if (!isOptionObject(option)) {
243
+ return { allow: new Set(), layoutOnly: false };
192
244
  }
193
- return;
194
- }
245
+ const { allow, layoutOnly } = option;
246
+ return {
247
+ allow: new Set(Array.isArray(allow) ? allow.filter(isTagName) : []),
248
+ layoutOnly: layoutOnly === true,
249
+ };
250
+ };
195
251
 
196
252
  /**
197
253
  * ESLint-style rule factory. Visits every JSX opening element and reports raw
@@ -199,17 +255,17 @@ function layoutUtilityOf(openingElement) {
199
255
  * `options[0].layoutOnly`, only layout hosts whose unconditional classes carry
200
256
  * a flex or grid display utility report.
201
257
  *
202
- * @param {{ options?: Array<{ allow?: string[], layoutOnly?: boolean }>, report: Function }} context
258
+ * @param {Context} context The rule context.
259
+ * @returns {Visitor} The visitor oxlint runs over the file.
203
260
  */
204
- export function create(context) {
205
- const allow = new Set(context.options?.[0]?.allow ?? []);
206
- const layoutOnly = context.options?.[0]?.layoutOnly === true;
261
+ export const create = (context) => {
262
+ const { allow, layoutOnly } = readOptions(context);
207
263
 
208
264
  return {
209
265
  JSXOpeningElement(node) {
210
266
  const { name } = node;
211
267
  // JSXMemberExpression (Foo.Bar) and JSXNamespacedName are never bare hosts.
212
- if (name?.type !== "JSXIdentifier") {
268
+ if (name.type !== "JSXIdentifier") {
213
269
  return;
214
270
  }
215
271
  const tag = name.name;
@@ -233,27 +289,29 @@ export function create(context) {
233
289
  }
234
290
  },
235
291
  };
236
- }
292
+ };
237
293
 
238
294
  /** Rule metadata + factory, ready to slot into the plugin's `rules` map. */
239
- export const rule = {
240
- meta: {
241
- type: "problem",
242
- docs: {
243
- description:
244
- "Disallow raw lowercase HTML host elements - use the design-system component vocabulary.",
245
- },
246
- messages: {},
247
- schema: [
248
- {
249
- type: "object",
250
- properties: {
251
- allow: { type: "array", items: { type: "string" } },
252
- layoutOnly: { type: "boolean" },
253
- },
254
- additionalProperties: false,
295
+ export const rule =
296
+ /** @satisfies {Rule} */
297
+ ({
298
+ meta: {
299
+ type: "problem",
300
+ docs: {
301
+ description:
302
+ "Disallow raw lowercase HTML host elements - use the design-system component vocabulary.",
255
303
  },
256
- ],
257
- },
258
- create,
259
- };
304
+ messages: {},
305
+ schema: [
306
+ {
307
+ type: "object",
308
+ properties: {
309
+ allow: { type: "array", items: { type: "string" } },
310
+ layoutOnly: { type: "boolean" },
311
+ },
312
+ additionalProperties: false,
313
+ },
314
+ ],
315
+ },
316
+ create,
317
+ });
@@ -6,6 +6,11 @@
6
6
  //
7
7
  // @doctrine Use `as` to change a layout primitive's element; use `render` only to swap in a component, never `render={<Box as=.../>}`.
8
8
 
9
+ /** @typedef {Parameters<import("oxlint/plugins-dev").RuleTester["run"]>[1]} Rule */
10
+ /** @typedef {NonNullable<Rule["create"]>} Create */
11
+ /** @typedef {Parameters<Create>[0]} Context */
12
+ /** @typedef {ReturnType<Create>} Visitor */
13
+
9
14
  /**
10
15
  * Layout primitives that share the closed `as` / `render` contract. A
11
16
  * `render={<Name as=... />}` on any prop is the mistake, so the set is every
@@ -34,73 +39,73 @@ export const LAYOUT_PRIMITIVES = new Set([
34
39
  * Build the diagnostic message for a banned `render={<Primitive as=... />}`.
35
40
  *
36
41
  * @param {string} primitive component name used inside `render`
37
- * @returns {string}
42
+ * @returns {string} The message telling the author to pass `as` to the outer element.
38
43
  */
39
- export function messageFor(primitive) {
40
- return `render={<${primitive} as=... />} nests a layout primitive only to set a tag. Pass \`as\` directly on the outer element; use \`render\` only to swap in a component (e.g. a router Link).`;
41
- }
44
+ export const messageFor = (primitive) =>
45
+ `render={<${primitive} as=... />} nests a layout primitive only to set a tag. Pass \`as\` directly on the outer element; use \`render\` only to swap in a component (e.g. a router Link).`;
42
46
 
43
47
  /**
44
48
  * ESLint-style rule factory. Visits every layout primitive whose `render` prop
45
49
  * is another layout-primitive element carrying an `as` attribute.
46
50
  *
47
- * @param {{ report: Function }} context
51
+ * @param {Context} context The rule context.
52
+ * @returns {Visitor} The visitor oxlint runs over the file.
48
53
  */
49
- export function create(context) {
50
- return {
51
- JSXOpeningElement(opening) {
52
- // Only a layout primitive as the OUTER element: it has `as`, so nesting is
53
- // needless. A trigger that has no `as` (TooltipTrigger, say) legitimately
54
- // takes `render={<Box as="span" />}` to get a tag.
54
+ export const create = (context) => ({
55
+ JSXOpeningElement(opening) {
56
+ // Only a layout primitive as the OUTER element: it has `as`, so nesting is
57
+ // needless. A trigger that has no `as` (TooltipTrigger, say) legitimately
58
+ // takes `render={<Box as="span" />}` to get a tag.
59
+ if (
60
+ opening.name.type !== "JSXIdentifier" ||
61
+ !LAYOUT_PRIMITIVES.has(opening.name.name)
62
+ ) {
63
+ return;
64
+ }
65
+ for (const attribute of opening.attributes) {
55
66
  if (
56
- opening.name?.type !== "JSXIdentifier" ||
57
- !LAYOUT_PRIMITIVES.has(opening.name.name)
67
+ attribute.type !== "JSXAttribute" ||
68
+ attribute.name.type !== "JSXIdentifier" ||
69
+ attribute.name.name !== "render"
58
70
  ) {
59
- return;
71
+ continue;
60
72
  }
61
- for (const attribute of opening.attributes) {
62
- if (
63
- attribute.type !== "JSXAttribute" ||
64
- attribute.name?.type !== "JSXIdentifier" ||
65
- attribute.name.name !== "render"
66
- ) {
67
- continue;
68
- }
69
- const element = attribute.value?.expression;
70
- if (element?.type !== "JSXElement") {
71
- continue;
72
- }
73
- const {name} = element.openingElement;
74
- if (
75
- name?.type !== "JSXIdentifier" ||
76
- !LAYOUT_PRIMITIVES.has(name.name)
77
- ) {
78
- continue;
79
- }
80
- const hasAs = element.openingElement.attributes.some(
81
- (inner) =>
82
- inner.type === "JSXAttribute" &&
83
- inner.name?.type === "JSXIdentifier" &&
84
- inner.name.name === "as"
85
- );
86
- if (hasAs) {
87
- context.report({ node: element, message: messageFor(name.name) });
88
- }
73
+ if (attribute.value?.type !== "JSXExpressionContainer") {
74
+ continue;
89
75
  }
90
- },
91
- };
92
- }
76
+ const element = attribute.value.expression;
77
+ if (element.type !== "JSXElement") {
78
+ continue;
79
+ }
80
+ const { name } = element.openingElement;
81
+ if (name.type !== "JSXIdentifier" || !LAYOUT_PRIMITIVES.has(name.name)) {
82
+ continue;
83
+ }
84
+ const hasAs = element.openingElement.attributes.some(
85
+ (inner) =>
86
+ inner.type === "JSXAttribute" &&
87
+ inner.name.type === "JSXIdentifier" &&
88
+ inner.name.name === "as"
89
+ );
90
+ if (hasAs) {
91
+ context.report({ node: element, message: messageFor(name.name) });
92
+ }
93
+ }
94
+ },
95
+ });
93
96
 
94
97
  /** Rule metadata + factory, ready to slot into the plugin's `rules` map. */
95
- export const rule = {
96
- meta: {
97
- type: "problem",
98
- docs: {
99
- description:
100
- "Disallow render={<Box as=... />}: pass `as` to the outer layout primitive instead.",
98
+ export const rule =
99
+ /** @satisfies {Rule} */
100
+ ({
101
+ meta: {
102
+ type: "problem",
103
+ docs: {
104
+ description:
105
+ "Disallow render={<Box as=... />}: pass `as` to the outer layout primitive instead.",
106
+ },
107
+ messages: {},
108
+ schema: [],
101
109
  },
102
- messages: {},
103
- schema: [],
104
- },
105
- create,
106
- };
110
+ create,
111
+ });
@@ -1,5 +1,58 @@
1
- /**
2
- * The class-string scanner is a command, run as the `materia-ui-lint` bin. It
3
- * exports nothing; importing it runs the scan.
4
- */
5
- export {};
1
+ // GENERATED by scripts/generate-lint-types.ts from the JSDoc in lint/scan-classes.mjs - do not edit.
2
+ // Regenerate with `pnpm --filter @instruments/materia-ui generate:lint-types`.
3
+
4
+ export interface Options {
5
+ /**
6
+ * Files and directories to scan.
7
+ */
8
+ paths: string[];
9
+ /**
10
+ * Path fragments to skip.
11
+ */
12
+ excludes: string[];
13
+ /**
14
+ * Whether the `hex` rule is on.
15
+ */
16
+ hex: boolean;
17
+ /**
18
+ * Whether the `arbitrary` rule is on.
19
+ */
20
+ arbitrary: boolean;
21
+ /**
22
+ * Whether to print a JSON document instead of text lines.
23
+ */
24
+ json: boolean;
25
+ /**
26
+ * Whether to print usage and stop.
27
+ */
28
+ help: boolean;
29
+ }
30
+ export type RuleName = "hex" | "arbitrary";
31
+ export interface Region {
32
+ /**
33
+ * Offset of the region in the file.
34
+ */
35
+ start: number;
36
+ /**
37
+ * The region's text.
38
+ */
39
+ body: string;
40
+ }
41
+ export interface Violation {
42
+ /**
43
+ * The rule that matched.
44
+ */
45
+ rule: RuleName;
46
+ /**
47
+ * The offending text.
48
+ */
49
+ value: string;
50
+ /**
51
+ * One-based line of the match.
52
+ */
53
+ line: number;
54
+ /**
55
+ * One-based column of the match.
56
+ */
57
+ column: number;
58
+ }