meno-core 1.1.4 → 1.1.5

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.
@@ -178,8 +178,10 @@ describe('utilityClassMapper', () => {
178
178
  });
179
179
 
180
180
  test('handles properties without a Tailwind root via arbitrary properties', () => {
181
- const classes = stylesToClasses({ textOverflow: 'ellipsis' });
182
- expect(classes).toContain('[text-overflow:ellipsis]');
181
+ // textOverflow graduated to the `text-ellipsis` static; clip-path still has no root.
182
+ const classes = stylesToClasses({ clipPath: 'circle(50%)' });
183
+ expect(classes).toContain('[clip-path:circle(50%)]');
184
+ expect(stylesToClasses({ textOverflow: 'ellipsis' })).toContain('text-ellipsis');
183
185
  });
184
186
 
185
187
  test('align-items stretch maps to a distinct class from flex-start', () => {
@@ -283,6 +285,122 @@ describe('utilityClassMapper', () => {
283
285
  expect(classToStyle('translate-y-4')).toEqual({ prop: 'translate', value: '0 16px' });
284
286
  });
285
287
 
288
+ test('filter functions wrap the value in their function', () => {
289
+ expect(classToStyle('blur-[70px]')).toEqual({ prop: 'filter', value: 'blur(70px)' });
290
+ expect(classToStyle('backdrop-blur-[8px]')).toEqual({ prop: 'backdropFilter', value: 'blur(8px)' });
291
+ expect(classToStyle('blur-(--glow)')).toEqual({ prop: 'filter', value: 'blur(var(--glow))' });
292
+ expect(classToStyle('drop-shadow-[0_4px_6px_#0003]')).toEqual({
293
+ prop: 'filter',
294
+ value: 'drop-shadow(0 4px 6px #0003)',
295
+ });
296
+ // Length/shadow named scales are deliberately not absorbed (no baked theme values).
297
+ expect(classToStyle('blur-sm')).toBeNull();
298
+ expect(classToStyle('drop-shadow-md')).toBeNull();
299
+ });
300
+
301
+ test('filter numeric steps follow Tailwind per-function scales', () => {
302
+ expect(classToStyle('brightness-50')).toEqual({ prop: 'filter', value: 'brightness(0.5)' });
303
+ expect(classToStyle('contrast-125')).toEqual({ prop: 'filter', value: 'contrast(1.25)' });
304
+ expect(classToStyle('saturate-150')).toEqual({ prop: 'filter', value: 'saturate(1.5)' });
305
+ expect(classToStyle('grayscale-50')).toEqual({ prop: 'filter', value: 'grayscale(50%)' });
306
+ expect(classToStyle('invert-25')).toEqual({ prop: 'filter', value: 'invert(25%)' });
307
+ expect(classToStyle('hue-rotate-90')).toEqual({ prop: 'filter', value: 'hue-rotate(90deg)' });
308
+ expect(classToStyle('backdrop-opacity-50')).toEqual({ prop: 'backdropFilter', value: 'opacity(0.5)' });
309
+ // Bare 100% switches are statics.
310
+ expect(classToStyle('grayscale')).toEqual({ prop: 'filter', value: 'grayscale(100%)' });
311
+ expect(classToStyle('invert')).toEqual({ prop: 'filter', value: 'invert(100%)' });
312
+ expect(classToStyle('backdrop-sepia')).toEqual({ prop: 'backdropFilter', value: 'sepia(100%)' });
313
+ expect(classToStyle('filter-none')).toEqual({ prop: 'filter', value: 'none' });
314
+ });
315
+
316
+ test('single filter-function values emit the compact class and round-trip', () => {
317
+ expect(stylesToClasses({ filter: 'blur(70px)' })).toEqual(['blur-[70px]']);
318
+ expect(stylesToClasses({ filter: 'brightness(0.5)' })).toEqual(['brightness-50']);
319
+ expect(stylesToClasses({ filter: 'grayscale(100%)' })).toEqual(['grayscale']);
320
+ expect(stylesToClasses({ filter: 'grayscale(50%)' })).toEqual(['grayscale-50']);
321
+ expect(stylesToClasses({ filter: 'hue-rotate(90deg)' })).toEqual(['hue-rotate-90']);
322
+ expect(stylesToClasses({ filter: 'drop-shadow(0 4px 6px #0003)' })).toEqual(['drop-shadow-[0_4px_6px_#0003]']);
323
+ expect(stylesToClasses({ filter: 'saturate(var(--sat))' })).toEqual(['saturate-(--sat)']);
324
+ expect(stylesToClasses({ backdropFilter: 'blur(8px)' })).toEqual(['backdrop-blur-[8px]']);
325
+ expect(stylesToClasses({ backdropFilter: 'opacity(0.5)' })).toEqual(['backdrop-opacity-50']);
326
+ // Multi-function values have no compact spelling → arbitrary form on the filter root.
327
+ expect(stylesToClasses({ filter: 'blur(4px) brightness(0.5)' })).toEqual(['filter-[blur(4px)_brightness(0.5)]']);
328
+ expect(classesToStyles(['blur-[70px]'])).toEqual({ base: { filter: 'blur(70px)' } });
329
+ expect(classesToStyles(['brightness-50'])).toEqual({ base: { filter: 'brightness(0.5)' } });
330
+ });
331
+
332
+ test('per-axis skew and scale compose into transform/scale', () => {
333
+ expect(classToStyle('skew-x-[10deg]')).toEqual({ prop: 'transform', value: 'skewX(10deg)' });
334
+ expect(classToStyle('skew-y-3')).toEqual({ prop: 'transform', value: 'skewY(3deg)' });
335
+ expect(classToStyle('scale-x-[1.2]')).toEqual({ prop: 'scale', value: '1.2 1' });
336
+ expect(classToStyle('scale-y-95')).toEqual({ prop: 'scale', value: '1 0.95' });
337
+ });
338
+
339
+ test('side border-radius restores both corners on round-trip', () => {
340
+ expect(classesToStyles(['rounded-t-lg'])).toEqual({
341
+ base: { borderTopLeftRadius: 'var(--radius-lg)', borderTopRightRadius: 'var(--radius-lg)' },
342
+ });
343
+ expect(classesToStyles(['rounded-l-[8px]'])).toEqual({
344
+ base: { borderTopLeftRadius: '8px', borderBottomLeftRadius: '8px' },
345
+ });
346
+ expect(classesToStyles(['rounded-b-full'])).toEqual({
347
+ base: { borderBottomLeftRadius: '9999px', borderBottomRightRadius: '9999px' },
348
+ });
349
+ // Corner forms are unaffected (single corner, no mirror).
350
+ expect(classesToStyles(['rounded-tl-[8px]'])).toEqual({ base: { borderTopLeftRadius: '8px' } });
351
+ });
352
+
353
+ test('long-tail Tailwind vocabulary (backgrounds, decoration, SVG, logical inset, scroll)', () => {
354
+ expect(classToStyle('bg-cover')).toEqual({ prop: 'backgroundSize', value: 'cover' });
355
+ expect(classToStyle('bg-clip-text')).toEqual({ prop: 'backgroundClip', value: 'text' });
356
+ expect(classToStyle('float-left')).toEqual({ prop: 'float', value: 'left' });
357
+ expect(classToStyle('text-ellipsis')).toEqual({ prop: 'textOverflow', value: 'ellipsis' });
358
+ expect(classToStyle('text-balance')).toEqual({ prop: 'textWrap', value: 'balance' });
359
+ // Shared `decoration` root disambiguates by value type; `stroke` likewise.
360
+ expect(classToStyle('decoration-2')).toEqual({ prop: 'textDecorationThickness', value: '2px' });
361
+ expect(classToStyle('decoration-[#f00]')).toEqual({ prop: 'textDecorationColor', value: '#f00' });
362
+ expect(classToStyle('decoration-wavy')).toEqual({ prop: 'textDecorationStyle', value: 'wavy' });
363
+ expect(classToStyle('underline-offset-2')).toEqual({ prop: 'textUnderlineOffset', value: '2px' });
364
+ expect(classToStyle('outline-1')).toEqual({ prop: 'outlineWidth', value: '1px' });
365
+ expect(classToStyle('outline-dashed')).toEqual({ prop: 'outlineStyle', value: 'dashed' });
366
+ expect(classToStyle('stroke-2')).toEqual({ prop: 'strokeWidth', value: '2' });
367
+ expect(classToStyle('stroke-[#f00]')).toEqual({ prop: 'stroke', value: '#f00' });
368
+ expect(classToStyle('fill-current')).toEqual({ prop: 'fill', value: 'currentColor' });
369
+ expect(classToStyle('caret-[#f00]')).toEqual({ prop: 'caretColor', value: '#f00' });
370
+ expect(classToStyle('start-0')).toEqual({ prop: 'insetInlineStart', value: '0px' });
371
+ expect(classToStyle('end-4')).toEqual({ prop: 'insetInlineEnd', value: '16px' });
372
+ expect(classToStyle('indent-4')).toEqual({ prop: 'textIndent', value: '16px' });
373
+ expect(classToStyle('border-spacing-2')).toEqual({ prop: 'borderSpacing', value: '8px' });
374
+ expect(classToStyle('scroll-mt-2')).toEqual({ prop: 'scrollMarginTop', value: '8px' });
375
+ expect(classToStyle('touch-pan-x')).toEqual({ prop: 'touchAction', value: 'pan-x' });
376
+ expect(classToStyle('overscroll-contain')).toEqual({ prop: 'overscrollBehavior', value: 'contain' });
377
+ expect(classToStyle('break-inside-avoid')).toEqual({ prop: 'breakInside', value: 'avoid' });
378
+ expect(classToStyle('auto-cols-fr')).toEqual({ prop: 'gridAutoColumns', value: 'minmax(0, 1fr)' });
379
+ expect(classToStyle('rounded-tr-xl')).toEqual({ prop: 'borderTopRightRadius', value: 'var(--radius-xl)' });
380
+ expect(classToStyle('caption-top')).toEqual({ prop: 'captionSide', value: 'top' });
381
+ expect(classToStyle('transform-gpu')).toEqual({ prop: 'transform', value: 'translateZ(0)' });
382
+ expect(classToStyle('animate-none')).toEqual({ prop: 'animation', value: 'none' });
383
+ });
384
+
385
+ test('static/keyword additions parse and forward-map', () => {
386
+ expect(classToStyle('italic')).toEqual({ prop: 'fontStyle', value: 'italic' });
387
+ expect(classToStyle('isolate')).toEqual({ prop: 'isolation', value: 'isolate' });
388
+ expect(classToStyle('border-collapse')).toEqual({ prop: 'borderCollapse', value: 'collapse' });
389
+ expect(classToStyle('tabular-nums')).toEqual({ prop: 'fontVariantNumeric', value: 'tabular-nums' });
390
+ expect(classToStyle('snap-x')).toEqual({ prop: 'scrollSnapType', value: 'x mandatory' });
391
+ expect(classToStyle('mix-blend-multiply')).toEqual({ prop: 'mixBlendMode', value: 'multiply' });
392
+ expect(classToStyle('will-change-scroll')).toEqual({ prop: 'willChange', value: 'scroll-position' });
393
+ expect(classToStyle('columns-3')).toEqual({ prop: 'columns', value: '3' });
394
+ expect(classToStyle('h-dvh')).toEqual({ prop: 'height', value: '100dvh' });
395
+ // Read-side aliases resolve to the canonical value; forward keeps the canonical class.
396
+ expect(classToStyle('text-nowrap')).toEqual({ prop: 'whiteSpace', value: 'nowrap' });
397
+ expect(classToStyle('break-words')).toEqual({ prop: 'overflowWrap', value: 'break-word' });
398
+ expect(stylesToClasses({ whiteSpace: 'nowrap' })).toEqual(['whitespace-nowrap']);
399
+ expect(stylesToClasses({ mixBlendMode: 'multiply' })).toEqual(['mix-blend-multiply']);
400
+ expect(stylesToClasses({ columns: '3' })).toEqual(['columns-3']);
401
+ expect(stylesToClasses({ height: '100dvh' })).toEqual(['h-dvh']);
402
+ });
403
+
286
404
  test('transition longhands compose; duration/ease override the shorthand', () => {
287
405
  expect(classToStyle('duration-300')).toEqual({ prop: 'transitionDuration', value: '300ms' });
288
406
  expect(classToStyle('delay-150')).toEqual({ prop: 'transitionDelay', value: '150ms' });
@@ -21,6 +21,10 @@ import {
21
21
  borderPresets,
22
22
  SPACING_SCALE_ROOTS,
23
23
  SPACING_SCALE_REVERSE,
24
+ FILTER_FN_ROOTS,
25
+ FILTER_ROOT_BY_PROP_FN,
26
+ filterArgToStep,
27
+ ROUNDED_SIDE_CORNERS,
24
28
  } from './utilityClassConfig';
25
29
  import {
26
30
  camelToKebab,
@@ -168,6 +172,32 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
168
172
 
169
173
  let className: string | null = null;
170
174
 
175
+ // Tailwind filter functions: a filter/backdrop-filter value that is a SINGLE function call
176
+ // emits the compact functional class — `blur(70px)` → `blur-[70px]`, `brightness(0.5)` →
177
+ // `brightness-50`, `saturate(var(--x))` → `saturate-(--x)` — mirroring the parser's filter
178
+ // branch so hand-authored filter classes survive a style round-trip. Multi-function values
179
+ // (`blur(4px) brightness(.5)`) and nested-paren args have no compact spelling → arbitrary form.
180
+ // (The exact 100% switches — `grayscale(100%)` → bare `grayscale` — hit the static table above.)
181
+ if (prop === 'filter' || prop === 'backdropFilter') {
182
+ const property = camelToKebab(prop);
183
+ const varForm = stringValue.match(/^([a-z-]+)\(\s*var\((--[\w-]+)\)\s*\)$/);
184
+ const plainForm = varForm ? null : stringValue.match(/^([a-z-]+)\(([^()]+)\)$/);
185
+ const fnRoot = FILTER_ROOT_BY_PROP_FN.get(`${property}|${(varForm ?? plainForm)?.[1]}`);
186
+ if (fnRoot && varForm) {
187
+ className = `${fnRoot}-(${varForm[2]})`;
188
+ } else if (fnRoot && plainForm) {
189
+ const arg = (plainForm[2] ?? '').trim();
190
+ const scale = FILTER_FN_ROOTS[fnRoot]?.scale;
191
+ const step = scale ? filterArgToStep(scale, arg) : null;
192
+ if (step != null) {
193
+ className = `${fnRoot}-${step}`;
194
+ } else {
195
+ const encoded = encodeArbitraryValue(arg);
196
+ if (encoded) className = `${fnRoot}-[${encoded}]`;
197
+ }
198
+ }
199
+ }
200
+
171
201
  // Named keyword values: width: 100% → w-full, margin: auto → m-auto
172
202
  const kwTable = keywordValues[root];
173
203
  if (kwTable) {
@@ -554,6 +584,13 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
554
584
  if (cleanClass.startsWith('size-') && styleEntry.prop === 'width') {
555
585
  styles[breakpoint]!.height = styleEntry.value;
556
586
  }
587
+ // Side border-radius (`rounded-t-*`) covers TWO corners with no CSS shorthand — classToStyle
588
+ // returns the first corner; mirror the value onto the second (same pattern as `size-*`).
589
+ const sideRoot = cleanClass.match(/^(rounded-[trbl])-/)?.[1];
590
+ const corners = sideRoot ? ROUNDED_SIDE_CORNERS[sideRoot] : undefined;
591
+ if (corners) {
592
+ styles[breakpoint]![cssPropertyToCamel(corners[1] ?? '')] = styleEntry.value;
593
+ }
557
594
  }
558
595
  }
559
596
 
@@ -31,6 +31,8 @@ import {
31
31
  EASE_TIMING,
32
32
  TRANSITION_PROPERTY_VALUES,
33
33
  TRANSITION_DEFAULT_TIMING,
34
+ FILTER_FN_ROOTS,
35
+ filterStepToArg,
34
36
  } from './utilityClassConfig';
35
37
  import { isCssNamedColor } from './cssNamedColors';
36
38
  import { THEME_SCALE_BY_CLASS, themeScaleValue } from './tailwindThemeScale';
@@ -252,6 +254,10 @@ export function resolveRootProperty(root: string, value: string): string {
252
254
  if (isLengthValue(value)) return 'outline-width';
253
255
  return 'outline';
254
256
  }
257
+ case 'decoration':
258
+ return isLengthValue(value) || value === 'from-font' ? 'text-decoration-thickness' : 'text-decoration-color';
259
+ case 'stroke':
260
+ return isColorValue(value) ? 'stroke' : 'stroke-width';
255
261
  default:
256
262
  return prefixToCSSProperty[root] ?? root;
257
263
  }
@@ -521,6 +527,62 @@ export function parseUtilityClass(className: string, knownTokens?: ReadonlySet<s
521
527
  }
522
528
 
523
529
  function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<string>): ParsedUtilityClass | null {
530
+ // Tailwind filter functions: the class value is an ARGUMENT to the function, not the property
531
+ // value itself — `blur-[70px]` → filter: blur(70px), `brightness-50` → filter: brightness(0.5),
532
+ // `backdrop-saturate-(--x)` → backdrop-filter: saturate(var(--x)). Must run before the generic
533
+ // arbitrary/var branches, which would assign the bare argument to filter (invalid CSS). Numeric
534
+ // steps follow Tailwind's per-function scale (see FILTER_FN_ROOTS); the length/shadow named
535
+ // scales (`blur-sm`, `drop-shadow-md`) are deliberately not absorbed — use the arbitrary form.
536
+ const filterFn = FILTER_FN_ROOTS[root];
537
+ if (filterFn) {
538
+ const { property, fn, scale } = filterFn;
539
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
540
+ const arg = decodeArbitraryValue(rest.slice(1, -1));
541
+ return { property, value: `${fn}(${arg})`, root, kind: 'arbitrary' };
542
+ }
543
+ const varArg = rest.match(/^\((--[\w-]+)\)$/);
544
+ if (varArg) {
545
+ return { property, value: `${fn}(var(${varArg[1]}))`, root, kind: 'var' };
546
+ }
547
+ if (scale && /^\d+(\.\d+)?$/.test(rest)) {
548
+ return { property, value: `${fn}(${filterStepToArg(scale, rest)})`, root, kind: 'numeric' };
549
+ }
550
+ return null;
551
+ }
552
+
553
+ // Per-axis skew (`skew-x-[10deg]`, `skew-y-3`): CSS has no individual skew property, so the
554
+ // value is wrapped into transform's skewX()/skewY(). Like Tailwind's numeric scale, a bare
555
+ // number is degrees. NB two skew classes on one element clobber each other (last `transform`
556
+ // wins) — same single-property limitation as translate-x/translate-y above.
557
+ if (root === 'skew-x' || root === 'skew-y') {
558
+ const fn = root === 'skew-x' ? 'skewX' : 'skewY';
559
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
560
+ return {
561
+ property: 'transform',
562
+ value: `${fn}(${decodeArbitraryValue(rest.slice(1, -1))})`,
563
+ root,
564
+ kind: 'arbitrary',
565
+ };
566
+ }
567
+ if (/^\d+(\.\d+)?$/.test(rest)) {
568
+ return { property: 'transform', value: `${fn}(${rest}deg)`, root, kind: 'numeric' };
569
+ }
570
+ return null;
571
+ }
572
+
573
+ // Per-axis scale (`scale-x-[1.2]`, `scale-y-95`): composed into the two-value `scale`
574
+ // property with the other axis at 1. Numeric steps are Tailwind's percentage-of-100.
575
+ if (root === 'scale-x' || root === 'scale-y') {
576
+ const pair = (v: string) => (root === 'scale-x' ? `${v} 1` : `1 ${v}`);
577
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
578
+ return { property: 'scale', value: pair(decodeArbitraryValue(rest.slice(1, -1))), root, kind: 'arbitrary' };
579
+ }
580
+ if (/^\d+$/.test(rest)) {
581
+ return { property: 'scale', value: pair(String(Number(rest) / 100)), root, kind: 'numeric' };
582
+ }
583
+ return null;
584
+ }
585
+
524
586
  // Arbitrary value: root-[value]
525
587
  if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
526
588
  const value = decodeArbitraryValue(rest.slice(1, -1));
@@ -546,6 +608,22 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
546
608
  return { property, value, root, kind: 'var' };
547
609
  }
548
610
 
611
+ // Side/corner border-radius NAMED forms (`rounded-t-lg`, `rounded-tr-xl`, `rounded-l-full`):
612
+ // resolve the value exactly like the plain `rounded` root — theme scale → var(--radius-lg),
613
+ // keywords → px. Bracket/var forms are already handled by the generic branches above (the
614
+ // prefix map carries the property); the CSS generator expands side roots to both corners.
615
+ if (root.startsWith('rounded-') && prefixToCSSProperty[root]) {
616
+ const themed = THEME_SCALE_BY_CLASS.get(`rounded-${rest}`);
617
+ if (themed) {
618
+ return { property: prefixToCSSProperty[root] ?? root, value: themeScaleValue(themed), root, kind: 'var' };
619
+ }
620
+ const kw = keywordValues[root]?.[rest] ?? keywordValues.rounded?.[rest];
621
+ if (kw !== undefined) {
622
+ return { property: prefixToCSSProperty[root] ?? root, value: kw, root, kind: 'keyword' };
623
+ }
624
+ return null;
625
+ }
626
+
549
627
  // Border width/style family (`border-2`, `border-b`, `border-t-4`, `border-solid`, …).
550
628
  // Placed after the arbitrary `[..]` and var `(--x)` escapes so those win; colors
551
629
  // (`border-primary`) return null here and fall through to the color-token branch below.
@@ -614,6 +692,18 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
614
692
  if ((root === 'duration' || root === 'delay') && /^\d+$/.test(rest)) {
615
693
  return { property: prefixToCSSProperty[root] ?? root, value: `${rest}ms`, root, kind: 'numeric' };
616
694
  }
695
+ // Numeric px widths/offsets: `outline-1` → outline-width 1px, `underline-offset-2` → 2px,
696
+ // `decoration-2` → text-decoration-thickness 2px. (`stroke-2` stays unitless — SVG stroke-width.)
697
+ if ((root === 'outline' || root === 'outline-offset' || root === 'underline-offset') && /^\d+$/.test(rest)) {
698
+ const property = root === 'outline' ? 'outline-width' : (prefixToCSSProperty[root] ?? root);
699
+ return { property, value: `${rest}px`, root, kind: 'numeric' };
700
+ }
701
+ if (root === 'decoration' && /^\d+$/.test(rest)) {
702
+ return { property: 'text-decoration-thickness', value: `${rest}px`, root, kind: 'numeric' };
703
+ }
704
+ if (root === 'stroke' && /^\d+$/.test(rest)) {
705
+ return { property: 'stroke-width', value: rest, root, kind: 'numeric' };
706
+ }
617
707
  // `ease-in-out` → transition-timing-function: cubic-bezier(…).
618
708
  if (root === 'ease') {
619
709
  const timing = EASE_TIMING[rest];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"