All files / packages/fds-uif/generator-base/src/analyzer classNames.ts

93.33% Statements 28/30
92.3% Branches 24/26
100% Functions 10/10
96.15% Lines 25/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                              503x 11x   498x 290x   208x                     87x   87x 12x 46x 41x           75x 75x                       43x 43x 124x 92x         43x               385x 257x                             385x                                           118x 118x 118x 21x                 118x    
/**
 * Class Name Rules
 *
 * Builds conditional class name rules from UIF modifiers, states, and variants.
 */
 
import type { ResolvedUIF, ResolvedModifier, ResolvedStructure, ResolvedVariant } from '@fds-uif/schema';
 
import type { ClassNameRule } from '../types/index.js';
import { sanitizeIdentifier } from '../types/index.js';
 
/**
 * Convert a static class attribute value (string or string[]) to a flat array of class tokens.
 */
function parseStaticClass(staticClass: unknown): string[] {
  if (Array.isArray(staticClass)) {
    return staticClass.flatMap((c) => String(c).split(' ').filter(Boolean));
  }
  if (typeof staticClass === 'string') {
    return staticClass.split(' ').filter(Boolean);
  }
  return [];
}
 
type Conditional = { classes: string[]; condition: string };
 
/**
 * Build conditional class entries from a single modifier.
 * Grouped modifiers (with options) produce one equality condition per option;
 * boolean modifiers produce a single presence condition.
 */
function modifierToConditionals(modifier: ResolvedModifier): Conditional[] {
  Iif (modifier.attribute !== 'class') return [];
 
  if (modifier.options && modifier.options.length > 0) {
    return modifier.options
      .filter((o) => o.value)
      .map((o) => ({
        classes: [o.value],
        condition: `${sanitizeIdentifier(modifier.name)} === '${o.propValue}'`,
      }));
  }
 
  Eif (modifier.value) {
    return [{ classes: [modifier.value], condition: sanitizeIdentifier(modifier.name) }];
  }
 
  return [];
}
 
/**
 * Build conditional class entries from a single variant. One option = one equality
 * conditional. Empty `class` (the "no-class default" pattern) yields no entry so the
 * default doesn't accidentally clear classes the resolver would otherwise apply.
 */
function variantToConditionals(variant: ResolvedVariant): Conditional[] {
  const out: Conditional[] = [];
  for (const option of variant.options) {
    if (!option.class) continue;
    out.push({
      classes: [option.class],
      condition: `${sanitizeIdentifier(variant.name)} === '${option.value}'`,
    });
  }
  return out;
}
 
/**
 * Build a `ClassNameRule` from the constituent parts. Returns an empty list when
 * there's nothing to declare so callers can `ClassNameRule[]` cleanly.
 */
function ruleFrom(baseClasses: string[], conditional: Conditional[]): ClassNameRule[] {
  if (baseClasses.length === 0 && conditional.length === 0) return [];
  return [{ base: baseClasses, conditional }];
}
 
/**
 * Build class name rules for a single structure node from its own static class,
 * modifiers, AND variants. Used by `analyzeStructure` to attach per-node class
 * rules to non-root elements that declare local modifiers/variants.
 *
 * Variants here are read off the NODE (`structure.variants`), not the UIF root — a
 * variant declared on a child node only paints that node, not the component root.
 * This was the source of the "child variant class hoists to root" bug: previously
 * `buildClassNameRules` pulled every variant across the tree via getVariants(uif)
 * and slapped them onto the root's rule list. Now variants live on their own node.
 */
export function buildNodeClassNameRules(structure: ResolvedStructure): ClassNameRule[] {
  return ruleFrom(parseStaticClass(structure.attributes?.static?.class), [
    ...(structure.modifiers ?? []).flatMap(modifierToConditionals),
    ...(structure.variants ?? []).flatMap(variantToConditionals),
  ]);
}
 
/**
 * Build class name rules for the ROOT element of a component from its
 * root-level modifiers, state classes, and root-level variants.
 *
 * Reads variants from TWO root sources: `uif.variants` (the system layer's
 * top-level variants block) and `uif.structure.variants` (variants declared
 * on the root structure node). Does NOT walk descendants — pulling variants
 * recursively would re-introduce the class-hoisting bug where child-node
 * variants leak onto the root. Child-node variants are handled by
 * buildNodeClassNameRules attached to each child's metadata.
 */
export function buildClassNameRules(
  uif: ResolvedUIF,
  modifiers: ResolvedModifier[],
  stateClasses: Array<{ state: string; class: string }>,
): ClassNameRule[] {
  const baseClasses = parseStaticClass(uif.structure.attributes?.static?.class);
  const rootVariants = [...(uif.variants ?? []), ...(uif.structure.variants ?? [])];
  const conditional: Conditional[] = [
    ...stateClasses.map((sc) => ({
      classes: [sc.class],
      condition: sanitizeIdentifier(sc.state),
    })),
    ...modifiers.flatMap(modifierToConditionals),
    ...rootVariants.flatMap(variantToConditionals),
  ];
  // The root always emits a ClassNameRule (even when nothing's declared) so the
  // resolver can attach extra root classes from the generation context.
  return [{ base: baseClasses, conditional }];
}