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

97.14% Statements 68/70
73.23% Branches 52/71
100% Functions 19/19
98.24% Lines 56/57

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 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237                                                        119x     119x 38x       119x 60x       119x 29x       119x 44x       119x 293x 25x                   119x                                           119x 119x 196x 196x 188x 188x   8x   8x   119x         14x                     8x 3x 3x 3x 14x 14x   3x             38x     38x 29x 9x 8x           7x     38x                       60x   60x 30x 9x                     51x                                 10x 5x 2x                       29x 35x   10x 5x   5x               29x 78x 29x 29x   78x                   44x                
/**
 * Props Building
 *
 * Transforms UIF states, modifiers, variants, slots, and bound attributes into PropMetadata.
 */
 
import type {
  Deprecated,
  ResolvedState,
  ResolvedModifier,
  ResolvedVariant,
  ResolvedSlot,
} from '@fds-uif/schema';
 
import type { PropMetadata } from '../types/index.js';
import { sanitizeIdentifier } from '../types/index.js';
 
/**
 * Build props from extracted UIF data
 */
export function buildProps(
  states: ResolvedState[],
  modifiers: ResolvedModifier[],
  variants: ResolvedVariant[],
  slots: ResolvedSlot[],
  boundAttrs: Array<{ attribute: string; prop: string; required: boolean }>,
  inferDescriptions: boolean,
): PropMetadata[] {
  const props: PropMetadata[] = [];
 
  // States → props
  for (const state of states) {
    props.push(stateToProps(state, inferDescriptions));
  }
 
  // Modifiers → props
  for (const modifier of modifiers) {
    props.push(modifierToProps(modifier, inferDescriptions));
  }
 
  // Variants → props
  for (const variant of variants) {
    props.push(variantToProps(variant, inferDescriptions));
  }
 
  // Slots → props
  for (const slot of slots) {
    props.push(slotToProps(slot, inferDescriptions));
  }
 
  // Bound attributes → props (avoid duplicates)
  for (const attr of boundAttrs) {
    if (!props.some((p) => p.name === attr.prop)) {
      props.push({
        name: attr.prop,
        type: 'string',
        required: attr.required,
        description: inferDescriptions ? `Bound attribute: ${attr.attribute}` : undefined,
        source: 'attribute',
      });
    }
  }
 
  return dedupeProps(props);
}
 
/**
 * Collapse props that share a name into a single declaration.
 *
 * The expanded SLDS2 UIF structures declare rich child-level nodes where
 * unrelated siblings reuse generic variant/modifier names (e.g. ColorPicker's
 * dropdown and its tab panels both declare a `visibility` variant; several nodes
 * declare `hasError`). Because states/modifiers/variants are collected across the
 * whole tree, that produced one `@api` prop per occurrence — duplicate identifiers
 * that don't compile (TS2300).
 *
 * A prop name maps to exactly one component API prop, so we keep the first
 * occurrence and merge the rest into it. String-literal-union types are unioned so
 * every generated getter still type-checks — e.g. one node compares
 * `visibility === 'open'` and another `visibility === 'closed'`, so the merged type
 * must include both members. Non-literal types (boolean, string) are left as the
 * first occurrence; when duplicates disagree on a non-literal type we keep the
 * first deterministically rather than fabricate an unsafe union.
 */
function dedupeProps(props: PropMetadata[]): PropMetadata[] {
  const byName = new Map<string, PropMetadata>();
  for (const prop of props) {
    const existing = byName.get(prop.name);
    if (!existing) {
      byName.set(prop.name, { ...prop });
      continue;
    }
    existing.type = mergePropTypes(existing.type, prop.type);
    // A prop is required if any of its occurrences is required.
    existing.required = existing.required || prop.required;
  }
  return [...byName.values()];
}
 
/** True when a type string is a (single- or multi-member) string-literal union. */
function isStringLiteralType(type: string): boolean {
  return type.split('|').every((member) => /^\s*'[^']*'\s*$/.test(member));
}
 
/**
 * Merge two prop type strings. When both are string-literal unions, union their
 * distinct members preserving first-seen order (so every equality getter across
 * the merged nodes remains type-valid). Otherwise keep the first type — identical
 * types collapse to themselves, and genuinely conflicting non-literal types resolve
 * deterministically to the first occurrence.
 */
function mergePropTypes(a: string, b: string): string {
  if (a === b) return a;
  Eif (isStringLiteralType(a) && isStringLiteralType(b)) {
    const members: string[] = [];
    for (const member of [...a.split('|'), ...b.split('|')]) {
      const trimmed = member.trim();
      if (!members.includes(trimmed)) members.push(trimmed);
    }
    return members.join(' | ');
  }
  return a;
}
 
function stateToProps(state: ResolvedState, inferDescriptions: boolean): PropMetadata {
  // Sanitize state name to be a valid JS identifier
  const safeName = sanitizeIdentifier(state.name);
 
  let type: string;
  if (state.type === 'boolean') {
    type = 'boolean';
  } else if (state.type === 'enum' && state.options && state.options.length > 0) {
    type = state.options.map((v) => `'${v}'`).join(' | ');
  } else {
    // Non-boolean state with no enumerable options (e.g. an `enum` state that
    // declares `options: []`, meaning an open/dynamic value). Fall back to
    // `string` rather than emitting an empty type, which downstream generators
    // reject. Mirrors the `options.length > 0` guard in `modifierToProps`.
    type = 'string';
  }
 
  return {
    name: safeName,
    type,
    required: false,
    default: state.default,
    description: inferDescriptions ? (state.description ?? `State: ${state.name}`) : undefined,
    source: 'state',
    ...(state.deprecated ? { deprecated: state.deprecated } : undefined),
  };
}
 
function modifierToProps(modifier: ResolvedModifier, inferDescriptions: boolean): PropMetadata {
  const safeName = sanitizeIdentifier(modifier.name);
 
  if (modifier.options && modifier.options.length > 0) {
    const typeValues = modifier.options.map((o) => `'${o.propValue}'`).join(' | ');
    return {
      name: safeName,
      type: typeValues,
      required: false,
      default: modifier.default,
      description: inferDescriptions ? (modifier.description ?? `Modifier: ${modifier.name}`) : undefined,
      source: 'variant',
      ...(modifier.deprecated ? { deprecated: modifier.deprecated } : undefined),
    };
  }
 
  return {
    name: safeName,
    type: 'boolean',
    required: false,
    description: inferDescriptions ? (modifier.description ?? `Modifier: ${modifier.name}`) : undefined,
    source: 'modifier',
    ...(modifier.deprecated ? { deprecated: modifier.deprecated } : undefined),
  };
}
 
/**
 * Pick the value shared by every option, or `undefined` if any option is
 * missing the field or any two values differ. Used to lift {@link removeBy}
 * from per-option deprecations to a synthesized parent-prop deprecation
 * only when consistent across all options.
 */
function sharedField(options: { deprecated?: Deprecated }[], field: 'removeBy'): string | undefined {
  const values = options.map((o) => o.deprecated?.[field]);
  if (values.includes(undefined)) return undefined;
  return new Set(values).size === 1 ? (values[0] as string) : undefined;
}
 
/**
 * If every option of a variant or grouped modifier is deprecated, the prop
 * itself is effectively deprecated. Synthesize a {@link Deprecated} object
 * carrying forward:
 *
 * - `severity: 'error'` if any option escalates to error severity.
 * - `removeBy` when consistent across all options.
 */
function allOptionsDeprecated(options: { deprecated?: Deprecated }[]): Deprecated | undefined {
  Iif (options.length === 0) return undefined;
  if (!options.every((o) => o.deprecated)) return undefined;
 
  const anyError = options.some((o) => o.deprecated?.severity === 'error');
  const sharedRemoveBy = sharedField(options, 'removeBy');
 
  return {
    message: 'All options for this prop are deprecated.',
    ...(anyError ? { severity: 'error' as const } : undefined),
    ...(sharedRemoveBy ? { removeBy: sharedRemoveBy } : undefined),
  };
}
 
function variantToProps(variant: ResolvedVariant, inferDescriptions: boolean): PropMetadata {
  const safeName = sanitizeIdentifier(variant.name);
  const values = variant.options.map((o) => o.value);
  const deprecated = allOptionsDeprecated(variant.options);
  return {
    name: safeName,
    type: values.map((v) => `'${v}'`).join(' | '),
    required: false,
    default: variant.default ?? values[0],
    description: inferDescriptions ? (variant.description ?? `Variant: ${variant.name}`) : undefined,
    source: 'variant',
    ...(deprecated ? { deprecated } : undefined),
  };
}
 
function slotToProps(slot: ResolvedSlot, inferDescriptions: boolean): PropMetadata {
  return {
    name: slot.name === 'default' ? 'children' : slot.name,
    type: 'ReactNode', // Library-specific generators may override
    required: slot.required ?? false,
    description: inferDescriptions ? `Slot: ${slot.name}` : undefined,
    source: 'slot',
  };
}