slot-variants 1.6.1 → 1.6.2

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
@@ -711,7 +711,9 @@ card({ class: { base: 'shadow-xl', header: 'text-blue-700', body: 'min-h-24' } }
711
711
  // base: 'border shadow-xl', header: 'font-bold text-blue-700', body: 'py-4 min-h-24'
712
712
  ```
713
713
 
714
- Both `class` and `className` are supported, but `class` is prioritized when both are used in the same time.
714
+ Both `class` and `className` are supported, but `class` takes priority when both are passed at the same time.
715
+
716
+ The runtime `class`/`className` override is not part of the cache key — it is merged on top of the cached variant result on every call (and `postProcess`, if set, is re-applied to the merged output). Two calls with identical variants but different `class` values therefore share the same cached base result.
715
717
 
716
718
  ### Post-Processing
717
719
 
@@ -734,6 +736,10 @@ const button = sv('px-4 py-2 bg-blue-500', {
734
736
 
735
737
  The `postProcess` function is applied to each slot's final class string independently.
736
738
 
739
+ `sv()` and `cn()` concatenate classes in order without deduplicating or resolving conflicts — a class repeated across `base` and a variant value appears twice in the output, and conflicting Tailwind utilities are both kept. Use `postProcess: twMerge` when you need duplicates collapsed and later utilities to win at runtime.
740
+
741
+ For statically-defined classes, the bundled [ESLint / oxlint plugin](#eslint--oxlint-plugin) catches most duplication at lint time — before it ever reaches the output: `no-conflicting-classes` flags duplicate and same-namespace tokens within a call, and `no-shared-tokens` flags a class repeated across every value of a variant (which belongs in `base` instead). The two approaches complement each other — the plugin keeps your source clean, while `postProcess: twMerge` handles duplicates that only arise from dynamic, runtime-supplied classes.
742
+
737
743
  ### Shared Defaults with `createSV()`
738
744
 
739
745
  `createSV(defaults)` returns a pre-configured `sv()` that merges `defaults` into every config-based call. This avoids repeating the same options — most commonly `postProcess: twMerge` — across every component:
@@ -806,6 +812,8 @@ const button = sv('btn', {
806
812
  });
807
813
  ```
808
814
 
815
+ Setting `cacheSize` to `0` (or a negative number) disables caching entirely — every call recomputes its result. This is useful when variant combinations are effectively unbounded and you'd rather not retain any entries.
816
+
809
817
  Cache inspection and control methods (`getCacheSize`, `clearCache`) are exposed on the returned function only when `introspection: true` is set — see [Introspection](#introspection).
810
818
 
811
819
  ### Introspection
@@ -855,6 +863,36 @@ button.clearCache(); // clear all cached entries
855
863
 
856
864
  Without `introspection: true`, only the variant function itself is returned — accessing introspection or cache properties is a type error.
857
865
 
866
+ ### Errors & Validation
867
+
868
+ `sv()` validates both the config and the runtime props, throwing an `Error` on misconfiguration. TypeScript prevents most of these statically, but they still guard against untyped runtime input (e.g. a value coming from a form or API).
869
+
870
+ **Config-time** — thrown when `sv(config)` is evaluated (at module load, when the variant function is built):
871
+
872
+ | Message | Cause |
873
+ | --- | --- |
874
+ | `Required variant "X" is not defined in variants` | `requiredVariants` lists a variant that doesn't exist |
875
+ | `Required variant "X" cannot have a default value` | A variant is both in `requiredVariants` and `defaultVariants` |
876
+ | `Default variant "X" is not defined in variants` | `defaultVariants` references an unknown variant |
877
+ | `Default variant "X" has invalid value "V"` | A static default value isn't one of the variant's values |
878
+ | `Preset "P" references unknown variant "X"` | A preset references an unknown variant |
879
+ | `Preset "P" has invalid value "V" for variant "X"` | A preset value isn't one of the variant's values |
880
+ | `Compound matcher references unknown variant "X"` | A compound variant/slot matches on an unknown variant |
881
+ | `Compound matcher for variant "X" has invalid value "V"` | A compound matcher value isn't one of the variant's values |
882
+ | `Compound variant must define "class" or "className"` | A `compoundVariants` entry has no class value |
883
+ | `Compound slot must define "class" or "className"` | A `compoundSlots` entry has no class value |
884
+ | `Compound slot must define at least one slot` | A `compoundSlots` entry has an empty `slots` array |
885
+ | `Compound slot references unknown slot "S"` | A `compoundSlots` entry targets an undefined slot |
886
+ | `Multi slot references unknown slot "S"` | `multiSlots` lists a slot that doesn't exist |
887
+
888
+ **Runtime** — thrown when the returned variant function is called:
889
+
890
+ | Message | Cause |
891
+ | --- | --- |
892
+ | `Missing required variant: "X"` | A required variant wasn't provided (by prop or preset) |
893
+ | `Invalid value "V" for variant "X"` | A prop value isn't one of the variant's defined values |
894
+ | `Invalid preset "P"` | The `preset` prop names a preset that doesn't exist |
895
+
858
896
  ## TypeScript
859
897
 
860
898
  `slot-variants` is fully typed. Variant props are inferred from your config:
@@ -876,7 +914,7 @@ const button = sv('btn', {
876
914
  requiredVariants: ['intent']
877
915
  });
878
916
 
879
- // Extract the variant props type (excludes class/className)
917
+ // Extract the variant props type (excludes class, className, and preset)
880
918
  type ButtonProps = VariantProps<typeof button>;
881
919
  // { size?: 'sm' | 'lg' | undefined; intent: 'primary' | 'danger' }
882
920
  ```
@@ -1015,7 +1053,7 @@ Class values inside the config (`base`, `variants`, `slots`, and `compound*` `cl
1015
1053
  | `multiSlots` | `string[] \| boolean` | Slot names exposed as reconfigurable functions instead of strings; `true` makes every slot a function, `false` none |
1016
1054
  | `presets` | `Record<string, Partial<VariantProps>>` | Named combinations of variant values selectable via `preset` prop |
1017
1055
  | `postProcess` | `(className: string) => string` | Custom transformation applied to final class strings |
1018
- | `cacheSize` | `number` | Maximum number of cached results (default: `256`) |
1056
+ | `cacheSize` | `number` | Maximum number of cached results (default: `256`); `0` or a negative value disables caching |
1019
1057
  | `introspection` | `boolean` | When `true`, exposes variant/slot/preset introspection and cache methods on the returned function (default: `false`) |
1020
1058
 
1021
1059
  ## ESLint / oxlint Plugin
package/SKILL.md CHANGED
@@ -121,10 +121,10 @@ const customSV = createSV({ postProcess: twMerge, cacheSize: 512 });
121
121
  The library caches results automatically (default 256 entries). Each cache entry is one distinct combination of resolved variant values:
122
122
 
123
123
  ```
124
- maxEntries = (values+ 1) × (values+ 1) × ... × (values + 1)
124
+ maxEntries = factor₁ × factor₂ × ... × factor
125
125
  ```
126
126
 
127
- The `+ 1` counts the variant being left unset. Raise `cacheSize` only when `maxEntries` exceeds 256 — below that the cache never evicts. Cache inspection methods (`getCacheSize`, `clearCache`) are only exposed when `introspection: true`.
127
+ A variant's factor is its value count `+ 1` (the `+ 1` counts the variant being left unset). The `+ 1` is dropped — factor is just the value count — when the variant is required or has a static default, since it can never be unset. Function-based defaults still count as unset-able, so they keep the `+ 1`. Raise `cacheSize` only when `maxEntries` exceeds 256 — below that the cache never evicts. With `introspection: true`, `getMaxEntries()` returns this exact number, and `getCacheSize()`/`clearCache()` inspect the live cache.
128
128
 
129
129
  ### 10. Use Presets for Reusable Variant Combinations
130
130
 
@@ -139,7 +139,7 @@ button({ preset: 'cta' }); // applies size: 'lg', intent: 'primary'
139
139
 
140
140
  ### 11. Use Introspection for Single Source of Truth
141
141
 
142
- Set `introspection: true` to expose configuration and cache members on the returned function (off by default): `variantKeys`, `variants`, `slotKeys`, `slots`, `defaultVariants`, `requiredVariants`, `presetKeys`, `presets`, `getVariantValues(key)`, `getCacheSize()`, and `clearCache()`.
142
+ Set `introspection: true` to expose configuration and cache members on the returned function (off by default): `variantKeys`, `variants`, `slotKeys`, `slots`, `defaultVariants`, `requiredVariants`, `multiSlots`, `presetKeys`, `presets`, `getVariantValues(key)`, `getMaxEntries()`, `getCacheSize()`, and `clearCache()`.
143
143
 
144
144
  Without `introspection: true`, accessing these is a type error. Use it to centralize variant/slot definitions and reuse them across the codebase.
145
145
 
@@ -201,7 +201,7 @@ Class values inside the config (`base`, `variants` values, `slots` values, and `
201
201
 
202
202
  ## Exported Types
203
203
 
204
- - `ClassValue` — Valid input for `cn()` (string, array, object, boolean, null, undefined)
204
+ - `ClassValue` — Valid input for `cn()` (string, number, bigint, array, object, boolean, null, undefined)
205
205
  - `VariantProps<T, E>` — Extract variant props from an `sv()` return, optionally excluding keys
206
206
  - `VariantValue<T, K>` — Extract the value union for a single variant key, without `undefined`
207
207
  - `SlotClassProps<T>` — Extract the per-slot class injection shape from an `sv()` return type
@@ -254,17 +254,54 @@ var pushStringLiteralTokens = (node, slot, source, entries, sourceCode) => {
254
254
  }
255
255
  };
256
256
  var isStaticStringNode = (node) => getStaticStringText(node) !== null;
257
- var extractTokens = (node, slot, source, slotNames, entries, sourceCode) => {
257
+ var pushRecordKeyTokens = (prop, slot, source, entries, sourceCode) => {
258
+ const { key } = prop;
259
+ if (key.type === "Literal" && typeof key.value === "string") {
260
+ pushStringLiteralTokens(key, slot, source, entries, sourceCode);
261
+ return;
262
+ }
263
+ const token = getKeyName(prop);
264
+ if (token === null) {
265
+ return;
266
+ }
267
+ const [start, end] = sourceCode.getRange(key);
268
+ entries.push({ source, slot, token, start, end });
269
+ };
270
+ var extractRecordTokens = (node, slot, source, entries, sourceCode) => {
271
+ for (const prop of node.properties) {
272
+ if (prop.type !== "Property" || prop.computed) {
273
+ continue;
274
+ }
275
+ pushRecordKeyTokens(prop, slot, source, entries, sourceCode);
276
+ }
277
+ };
278
+ var extractTokens = (node, slot, source, slotNames, entries, sourceCode, cnStyle = false) => {
258
279
  if (isStaticStringNode(node)) {
259
280
  pushStringLiteralTokens(node, slot, source, entries, sourceCode);
260
281
  return;
261
282
  }
283
+ if (cnStyle && isLogicalClassStaticString(node)) {
284
+ pushStringLiteralTokens(node.right, slot, source, entries, sourceCode);
285
+ return;
286
+ }
262
287
  if (node.type === "ArrayExpression") {
263
288
  forEachStaticItem(node.elements, (element) => {
264
- extractTokens(element, slot, source, slotNames, entries, sourceCode);
289
+ extractTokens(
290
+ element,
291
+ slot,
292
+ source,
293
+ slotNames,
294
+ entries,
295
+ sourceCode,
296
+ cnStyle
297
+ );
265
298
  });
266
299
  return;
267
300
  }
301
+ if (cnStyle && node.type === "ObjectExpression") {
302
+ extractRecordTokens(node, slot, source, entries, sourceCode);
303
+ return;
304
+ }
268
305
  const slotKeyedProps = collectSlotKeyedProperties(node, slotNames);
269
306
  if (!slotKeyedProps) {
270
307
  return;
@@ -356,7 +393,7 @@ var collectConfigEntries = (config, slotNames, baseArgs, sourceCode) => {
356
393
  extract(slotValue, slotKey, baseSource);
357
394
  }
358
395
  for (const arg of baseArgs) {
359
- extract(arg, "base", baseSource);
396
+ extractTokens(arg, "base", baseSource, slotNames, entries, sourceCode, true);
360
397
  }
361
398
  const base = config.get("base");
362
399
  if (base) {
@@ -378,6 +415,7 @@ var reportDynamic = (context, node) => {
378
415
  context.report({ node, messageId: "dynamic" });
379
416
  };
380
417
  var isUndefinedIdentifier = (node) => node.type === "Identifier" && node.name === "undefined";
418
+ var isLogicalClassStaticString = (node) => node.type === "LogicalExpression" && node.operator === "&&" && isStaticStringNode(node.right);
381
419
  var forEachItemReportingSpread = (context, items, visit) => {
382
420
  for (const item of items) {
383
421
  if (!item) {
@@ -397,6 +435,9 @@ var checkClassValueIsStatic = (context, node, options = {}) => {
397
435
  if (isStaticStringNode(node)) {
398
436
  return;
399
437
  }
438
+ if (options.allowLogicalString && isLogicalClassStaticString(node)) {
439
+ return;
440
+ }
400
441
  if (node.type === "ArrayExpression") {
401
442
  forEachItemReportingSpread(context, node.elements, (element) => {
402
443
  if (options.allowNestedArrays === false) {
@@ -405,10 +446,17 @@ var checkClassValueIsStatic = (context, node, options = {}) => {
405
446
  return;
406
447
  }
407
448
  }
408
- checkClassValueIsStatic(context, element);
449
+ checkClassValueIsStatic(context, element, {
450
+ allowLogicalString: options.allowLogicalString === true,
451
+ allowClassRecord: options.allowClassRecord === true
452
+ });
409
453
  });
410
454
  return;
411
455
  }
456
+ if (options.allowClassRecord && node.type === "ObjectExpression") {
457
+ checkClassRecordKeys(context, node);
458
+ return;
459
+ }
412
460
  reportDynamic(context, node);
413
461
  };
414
462
  var checkConfigClassValueIsStatic = (context, node) => {
@@ -430,6 +478,10 @@ var forEachStaticProperty = (context, node, visit) => {
430
478
  visit(prop);
431
479
  }
432
480
  };
481
+ var checkClassRecordKeys = (context, node) => {
482
+ forEachStaticProperty(context, node, () => {
483
+ });
484
+ };
433
485
  var checkClassValueRecord = (context, node) => {
434
486
  if (node.type !== "ObjectExpression") {
435
487
  reportDynamic(context, node);
@@ -551,7 +603,10 @@ var checkSvConfig = (context, configNode) => {
551
603
  };
552
604
  var checkCnArguments = (context, args) => {
553
605
  forEachItemReportingSpread(context, args, (arg) => {
554
- checkClassValueIsStatic(context, arg);
606
+ checkClassValueIsStatic(context, arg, {
607
+ allowLogicalString: true,
608
+ allowClassRecord: true
609
+ });
555
610
  });
556
611
  };
557
612
  var getImportedName = (specifier) => {
@@ -829,7 +884,8 @@ var analyzeCnForRule = (context, args) => {
829
884
  baseSource,
830
885
  EMPTY_SLOT_NAMES,
831
886
  entries,
832
- context.sourceCode
887
+ context.sourceCode,
888
+ true
833
889
  );
834
890
  }
835
891
  const tokenMap = indexEntriesBySlotAndToken(entries).get("base");
@@ -229,17 +229,54 @@ var pushStringLiteralTokens = (node, slot, source, entries, sourceCode) => {
229
229
  }
230
230
  };
231
231
  var isStaticStringNode = (node) => getStaticStringText(node) !== null;
232
- var extractTokens = (node, slot, source, slotNames, entries, sourceCode) => {
232
+ var pushRecordKeyTokens = (prop, slot, source, entries, sourceCode) => {
233
+ const { key } = prop;
234
+ if (key.type === "Literal" && typeof key.value === "string") {
235
+ pushStringLiteralTokens(key, slot, source, entries, sourceCode);
236
+ return;
237
+ }
238
+ const token = getKeyName(prop);
239
+ if (token === null) {
240
+ return;
241
+ }
242
+ const [start, end] = sourceCode.getRange(key);
243
+ entries.push({ source, slot, token, start, end });
244
+ };
245
+ var extractRecordTokens = (node, slot, source, entries, sourceCode) => {
246
+ for (const prop of node.properties) {
247
+ if (prop.type !== "Property" || prop.computed) {
248
+ continue;
249
+ }
250
+ pushRecordKeyTokens(prop, slot, source, entries, sourceCode);
251
+ }
252
+ };
253
+ var extractTokens = (node, slot, source, slotNames, entries, sourceCode, cnStyle = false) => {
233
254
  if (isStaticStringNode(node)) {
234
255
  pushStringLiteralTokens(node, slot, source, entries, sourceCode);
235
256
  return;
236
257
  }
258
+ if (cnStyle && isLogicalClassStaticString(node)) {
259
+ pushStringLiteralTokens(node.right, slot, source, entries, sourceCode);
260
+ return;
261
+ }
237
262
  if (node.type === "ArrayExpression") {
238
263
  forEachStaticItem(node.elements, (element) => {
239
- extractTokens(element, slot, source, slotNames, entries, sourceCode);
264
+ extractTokens(
265
+ element,
266
+ slot,
267
+ source,
268
+ slotNames,
269
+ entries,
270
+ sourceCode,
271
+ cnStyle
272
+ );
240
273
  });
241
274
  return;
242
275
  }
276
+ if (cnStyle && node.type === "ObjectExpression") {
277
+ extractRecordTokens(node, slot, source, entries, sourceCode);
278
+ return;
279
+ }
243
280
  const slotKeyedProps = collectSlotKeyedProperties(node, slotNames);
244
281
  if (!slotKeyedProps) {
245
282
  return;
@@ -331,7 +368,7 @@ var collectConfigEntries = (config, slotNames, baseArgs, sourceCode) => {
331
368
  extract(slotValue, slotKey, baseSource);
332
369
  }
333
370
  for (const arg of baseArgs) {
334
- extract(arg, "base", baseSource);
371
+ extractTokens(arg, "base", baseSource, slotNames, entries, sourceCode, true);
335
372
  }
336
373
  const base = config.get("base");
337
374
  if (base) {
@@ -353,6 +390,7 @@ var reportDynamic = (context, node) => {
353
390
  context.report({ node, messageId: "dynamic" });
354
391
  };
355
392
  var isUndefinedIdentifier = (node) => node.type === "Identifier" && node.name === "undefined";
393
+ var isLogicalClassStaticString = (node) => node.type === "LogicalExpression" && node.operator === "&&" && isStaticStringNode(node.right);
356
394
  var forEachItemReportingSpread = (context, items, visit) => {
357
395
  for (const item of items) {
358
396
  if (!item) {
@@ -372,6 +410,9 @@ var checkClassValueIsStatic = (context, node, options = {}) => {
372
410
  if (isStaticStringNode(node)) {
373
411
  return;
374
412
  }
413
+ if (options.allowLogicalString && isLogicalClassStaticString(node)) {
414
+ return;
415
+ }
375
416
  if (node.type === "ArrayExpression") {
376
417
  forEachItemReportingSpread(context, node.elements, (element) => {
377
418
  if (options.allowNestedArrays === false) {
@@ -380,10 +421,17 @@ var checkClassValueIsStatic = (context, node, options = {}) => {
380
421
  return;
381
422
  }
382
423
  }
383
- checkClassValueIsStatic(context, element);
424
+ checkClassValueIsStatic(context, element, {
425
+ allowLogicalString: options.allowLogicalString === true,
426
+ allowClassRecord: options.allowClassRecord === true
427
+ });
384
428
  });
385
429
  return;
386
430
  }
431
+ if (options.allowClassRecord && node.type === "ObjectExpression") {
432
+ checkClassRecordKeys(context, node);
433
+ return;
434
+ }
387
435
  reportDynamic(context, node);
388
436
  };
389
437
  var checkConfigClassValueIsStatic = (context, node) => {
@@ -405,6 +453,10 @@ var forEachStaticProperty = (context, node, visit) => {
405
453
  visit(prop);
406
454
  }
407
455
  };
456
+ var checkClassRecordKeys = (context, node) => {
457
+ forEachStaticProperty(context, node, () => {
458
+ });
459
+ };
408
460
  var checkClassValueRecord = (context, node) => {
409
461
  if (node.type !== "ObjectExpression") {
410
462
  reportDynamic(context, node);
@@ -526,7 +578,10 @@ var checkSvConfig = (context, configNode) => {
526
578
  };
527
579
  var checkCnArguments = (context, args) => {
528
580
  forEachItemReportingSpread(context, args, (arg) => {
529
- checkClassValueIsStatic(context, arg);
581
+ checkClassValueIsStatic(context, arg, {
582
+ allowLogicalString: true,
583
+ allowClassRecord: true
584
+ });
530
585
  });
531
586
  };
532
587
  var getImportedName = (specifier) => {
@@ -804,7 +859,8 @@ var analyzeCnForRule = (context, args) => {
804
859
  baseSource,
805
860
  EMPTY_SLOT_NAMES,
806
861
  entries,
807
- context.sourceCode
862
+ context.sourceCode,
863
+ true
808
864
  );
809
865
  }
810
866
  const tokenMap = indexEntriesBySlotAndToken(entries).get("base");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slot-variants",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "Type-safe class name variants with slots",
5
5
  "type": "module",
6
6
  "exports": {
@@ -54,6 +54,7 @@
54
54
  "@types/estree": "1.0.9",
55
55
  "class-variance-authority": "0.7.1",
56
56
  "classnames": "2.5.1",
57
+ "clsx": "2.1.1",
57
58
  "eslint": "10.3.0",
58
59
  "fallow": "^2.62.0",
59
60
  "jiti": "2.7.0",