@siyahi/tools 0.1.9-next.32 → 0.1.9-next.33

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
@@ -49,6 +49,14 @@ flex-col gap-4">`, `<TableCell><span className="font-mono">`,
49
49
  A Vite+ or ESLint config can spread every rule at once:
50
50
  `import { recommended } from "@siyahi/tools/lint"`.
51
51
 
52
+ One more rule is for a component library's own types rather than for pages, so
53
+ it runs in every file it is turned on for and is not in `recommended`:
54
+ `siyahi/optional-accepts-undefined` rejects an optional property that does not
55
+ take undefined, such as `tone?: Tone`, and `--fix` writes
56
+ `tone?: Tone | undefined`. Under TypeScript's `exactOptionalPropertyTypes` a
57
+ page can only pass `tone: loss ? "negative" : undefined` to the second.
58
+ Siyahi runs it on its own package.
59
+
52
60
  ## Font preload
53
61
 
54
62
  A Vite plugin that adds `<link rel="preload" as="font">` tags to the built
package/lint/index.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // Oxlint (and ESLint) plugin `siyahi`. Every rule runs only in files that
3
3
  // import `@siyahi/ui`, so pages written before Siyahi stay outside the rules
4
4
  // without any configuration. Three rules come from @shadcn/lint; the other
5
- // four cover what it does not check.
5
+ // four cover what it does not check. One more, optional-accepts-undefined, is
6
+ // for a component library's own types: unscoped, and not in `recommended`.
6
7
  import { plugin as shadcn } from "@shadcn/lint";
7
8
 
8
9
  /** @typedef {{ sourceCode: { text: string }, report: (problem: { node: unknown, message: string }) => void }} Context */
@@ -138,6 +139,88 @@ const noChartThemeMap = {
138
139
  }),
139
140
  };
140
141
 
142
+ /** Types that already hold undefined, so they need no `| undefined`. */
143
+ const HOLDS_UNDEFINED = new Set(["TSUndefinedKeyword", "TSAnyKeyword", "TSUnknownKeyword", "TSVoidKeyword"]);
144
+ /** Types that bind looser than `|`, so a union with undefined wraps them in brackets. */
145
+ const LOOSE = new Set(["TSFunctionType", "TSConstructorType", "TSConditionalType"]);
146
+
147
+ /**
148
+ * @param {{ type: string, types?: { type: string }[], typeName?: { type: string, name?: string, right?: { name?: string } } }} type
149
+ * @returns {boolean}
150
+ */
151
+ function holdsUndefined(type) {
152
+ if (HOLDS_UNDEFINED.has(type.type)) return true;
153
+ if (type.type === "TSUnionType") return (type.types ?? []).some(holdsUndefined);
154
+ // React's ReactNode includes undefined.
155
+ const name = type.typeName?.type === "TSQualifiedName" ? type.typeName.right?.name : type.typeName?.name;
156
+ return type.type === "TSTypeReference" && name === "ReactNode";
157
+ }
158
+
159
+ /**
160
+ * Whether a node sits in the pattern a conditional type matches against, `T extends { x?: infer U } ? U : never`,
161
+ * where adding undefined would change what matches and what `infer` takes.
162
+ * @param {any} node
163
+ * @returns {boolean}
164
+ */
165
+ function inPattern(node) {
166
+ for (let child = node; child.parent; child = child.parent) {
167
+ if (child.parent.type === "TSConditionalType" && child.parent.extendsType === child) return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /** @type {Rule} */
173
+ const optionalAcceptsUndefined = {
174
+ meta: {
175
+ type: "problem",
176
+ fixable: "code",
177
+ docs: { description: "Require `| undefined` on an optional property, for pages under exactOptionalPropertyTypes." },
178
+ },
179
+ // Under exactOptionalPropertyTypes, `tone?: Tone` rejects `tone: loss ? "negative" : undefined`, the way a page
180
+ // writes a prop it sets only sometimes; `tone?: Tone | undefined` takes it.
181
+ create: (context) => {
182
+ const message =
183
+ "An optional property must also accept undefined: add `| undefined`, for pages under exactOptionalPropertyTypes.";
184
+ /**
185
+ * @param {any} node a property signature, class field or mapped type
186
+ * @param {any} type its type
187
+ */
188
+ function check(node, type) {
189
+ if (!type || holdsUndefined(type) || inPattern(node)) return;
190
+ context.report({
191
+ node,
192
+ message,
193
+ fix: (/** @type {any} */ fixer) =>
194
+ LOOSE.has(type.type)
195
+ ? [fixer.insertTextBefore(type, "("), fixer.insertTextAfter(type, ") | undefined")]
196
+ : fixer.insertTextAfter(type, " | undefined"),
197
+ });
198
+ }
199
+ return {
200
+ /** @param {any} node */
201
+ TSPropertySignature(node) {
202
+ if (node.optional) check(node, node.typeAnnotation?.typeAnnotation);
203
+ },
204
+ /** @param {any} node */
205
+ PropertyDefinition(node) {
206
+ if (node.optional) check(node, node.typeAnnotation?.typeAnnotation);
207
+ },
208
+ /** @param {any} node `[K in keyof T]?: T[K]` */
209
+ TSMappedType(node) {
210
+ if (node.optional === true || node.optional === "+") check(node, node.typeAnnotation);
211
+ },
212
+ /** @param {any} node `onSelect?(): void`, which has no type to add undefined to */
213
+ TSMethodSignature(node) {
214
+ if (!node.optional || inPattern(node)) return;
215
+ context.report({
216
+ node,
217
+ message: `${message} Write it as a property: \`onSelect?: (() => void) | undefined\`.`,
218
+ });
219
+ },
220
+ };
221
+ },
222
+ };
223
+
141
224
  /**
142
225
  * Options for shadcn's no-restyle, so every app gets the same policy from
143
226
  * `"siyahi/no-restyle": "error"` alone. Pages may place a component (margin,
@@ -163,24 +246,28 @@ function withOptions(rule, options) {
163
246
  };
164
247
  }
165
248
 
249
+ /** Rules for pages that use Siyahi. */
250
+ const pageRules = {
251
+ "no-color-literal": scoped(noColorLiteral),
252
+ "no-scheme-variant": scoped(noSchemeVariant),
253
+ "no-numeric-font-size": scoped(noNumericFontSize),
254
+ "no-chart-theme-map": scoped(noChartThemeMap),
255
+ "no-raw-colors": scoped(shadcn.rules["no-raw-colors"]),
256
+ "no-arbitrary-values": scoped(shadcn.rules["no-arbitrary-values"]),
257
+ "no-restyle": scoped(withOptions(shadcn.rules["no-restyle"], RESTYLE)),
258
+ };
259
+
166
260
  /** @type {{ meta: { name: string }, rules: Record<string, Rule> }} */
167
261
  const plugin = {
168
262
  meta: { name: "siyahi" },
169
- rules: {
170
- "no-color-literal": scoped(noColorLiteral),
171
- "no-scheme-variant": scoped(noSchemeVariant),
172
- "no-numeric-font-size": scoped(noNumericFontSize),
173
- "no-chart-theme-map": scoped(noChartThemeMap),
174
- "no-raw-colors": scoped(shadcn.rules["no-raw-colors"]),
175
- "no-arbitrary-values": scoped(shadcn.rules["no-arbitrary-values"]),
176
- "no-restyle": scoped(withOptions(shadcn.rules["no-restyle"], RESTYLE)),
177
- },
263
+ // optional-accepts-undefined is for a component library's own types, not for pages, which only use them.
264
+ rules: { ...pageRules, "optional-accepts-undefined": optionalAcceptsUndefined },
178
265
  };
179
266
 
180
267
  /**
181
- * Every rule at "error", for a Vite+ or ESLint config to spread.
268
+ * Every page rule at "error", for a Vite+ or ESLint config to spread.
182
269
  * @type {Record<string, "error">}
183
270
  */
184
- export const recommended = Object.fromEntries(Object.keys(plugin.rules).map((name) => [`siyahi/${name}`, "error"]));
271
+ export const recommended = Object.fromEntries(Object.keys(pageRules).map((name) => [`siyahi/${name}`, "error"]));
185
272
 
186
273
  export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/tools",
3
- "version": "0.1.9-next.32",
3
+ "version": "0.1.9-next.33",
4
4
  "description": "Checks and build helpers for Siyahi pages: a lint plugin, a font preload plugin, a theme contrast check, and screenshot and layout shift scripts.",
5
5
  "homepage": "https://siyahi.jatinbansal.com",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@ declare const plugin: {
19
19
  rules: Record<string, Rule>;
20
20
  };
21
21
  /**
22
- * Every rule at "error", for a Vite+ or ESLint config to spread.
22
+ * Every page rule at "error", for a Vite+ or ESLint config to spread.
23
23
  * @type {Record<string, "error">}
24
24
  */
25
25
  export declare const recommended: Record<string, "error">;