All files / packages/fds-uif/core/src/css hook-name-parser.ts

95.45% Statements 42/44
77.77% Branches 14/18
100% Functions 0/0
95.45% Lines 42/44

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                                  13x     13x             142x       339x       214x                         71x   1x   1x           70x   67x       3x             68x   1x     67x                     68x   148x               68x 191x   12x       56x                         68x     3x       65x       1x   1x           12x   12x                   1x     64x                                               71x 71x     71x       3x                       68x 68x 68x     68x     68x 68x     68x   68x                             93x    
/**
 * @fds-uif/core - Hook Name Parser
 *
 * Parses component hook names according to the SLDS naming schema:
 * `--slds-c-[component]-[element]-[category]-[property]-[attribute]-[state]`
 *
 * @see RFC: uif-styling-layer.md
 */
 
import type { ParsedHookName, HookCategory, HookAttribute, HookState } from './types.js';
import { HOOK_CATEGORIES, HOOK_ATTRIBUTES, HOOK_STATES } from './types.js';
 
// ============================================================================
// Constants
// ============================================================================
 
/** Regex to match component-level hooks (--slds-c-*) */
export const COMPONENT_HOOK_REGEX = /^--slds-c-[a-zA-Z0-9-]+$/;
 
/** Font-related properties that follow "font" category */
const FONT_PROPERTIES = ['size', 'weight', 'line', 'family', 'style'];
 
// ============================================================================
// Type Guards
// ============================================================================
 
function isHookCategory(value: string): value is HookCategory {
  return (HOOK_CATEGORIES as readonly string[]).includes(value);
}
 
function isHookAttribute(value: string): value is HookAttribute {
  return (HOOK_ATTRIBUTES as readonly string[]).includes(value);
}
 
function isHookState(value: string): value is HookState {
  return (HOOK_STATES as readonly string[]).includes(value);
}
 
// ============================================================================
// Parser
// ============================================================================
 
/**
 * Find category index in parts array.
 * Handles compound categories like "font-size", "font-weight".
 */
function findCategoryIndex(parts: string[]): { index: number; category: HookCategory | null } {
  // First, check for compound font properties
  for (let i = 0; i < parts.length - 1; i++) {
    if (parts[i] === 'font' && isHookCategory('font')) {
      const nextPart = parts[i + 1];
      if (FONT_PROPERTIES.includes(nextPart)) {
        return { index: i, category: 'font' };
      }
    }
  }
 
  // Check for other categories (search from end to avoid component name collisions)
  for (let i = parts.length - 1; i >= 0; i--) {
    if (isHookCategory(parts[i])) {
      return { index: i, category: parts[i] as HookCategory };
    }
  }
 
  return { index: -1, category: null };
}
 
/**
 * Find state in parts array (usually at the end).
 */
function findState(parts: string[]): { index: number; state: HookState | null } {
  for (let i = parts.length - 1; i >= 0; i--) {
    if (isHookState(parts[i])) {
      return { index: i, state: parts[i] as HookState };
    }
  }
  return { index: -1, state: null };
}
 
/**
 * Find attribute in parts array (before state if present).
 */
function findAttribute(
  parts: string[],
  searchEnd: number,
): { index: number; attribute: HookAttribute | null } {
  // Check for compound attributes first (e.g., "inline-start", "block-end")
  for (let i = searchEnd - 2; i >= 0; i--) {
    if (i + 1 < searchEnd) {
      const compoundAttr = parts.slice(i, searchEnd).join('-');
      if (isHookAttribute(compoundAttr)) {
        return { index: i, attribute: compoundAttr };
      }
    }
  }
 
  // Check single-part attributes
  for (let i = searchEnd - 1; i >= 0; i--) {
    const part = parts[i];
    if (isHookAttribute(part)) {
      return { index: i, attribute: part };
    }
  }
 
  return { index: -1, attribute: null };
}
 
/**
 * Extract property from parts between category and attribute/state.
 */
function extractProperty(
  parts: string[],
  categoryIndex: number,
  category: HookCategory | null,
  attributeIndex: number,
  stateIndex: number,
): string | null {
  const afterCategory = parts.slice(categoryIndex + 1);
 
  if (afterCategory.length === 0) {
    return null;
  }
 
  // Calculate property end boundary
  let propertyEnd = afterCategory.length;
 
  // Adjust for state (relative to afterCategory)
  if (stateIndex >= 0) {
    const stateRelativeIndex = stateIndex - categoryIndex - 1;
    if (stateRelativeIndex >= 0) {
      propertyEnd = Math.min(propertyEnd, stateRelativeIndex);
    }
  }
 
  // Adjust for attribute (relative to afterCategory)
  if (attributeIndex >= 0) {
    const attrRelativeIndex = attributeIndex - categoryIndex - 1;
    if (attrRelativeIndex >= 0) {
      propertyEnd = Math.min(propertyEnd, attrRelativeIndex);
    }
  }
 
  // Handle font category specially (font-size, font-weight, etc.)
  if (category === 'font' && afterCategory.length > 0) {
    // Handle line-height (two parts)
    if (afterCategory[0] === 'line' && afterCategory[1] === 'height') {
      return afterCategory.slice(0, Math.min(2, propertyEnd)).join('-');
    }
    return afterCategory.slice(0, propertyEnd).join('-') || null;
  }
 
  return propertyEnd > 0 ? afterCategory.slice(0, propertyEnd).join('-') : null;
}
 
/**
 * Parse a component hook name into semantic parts.
 *
 * @param hookName - Full hook name (e.g., "--slds-c-badge-icon-color-foreground-hover")
 * @returns Parsed hook name components
 *
 * @example
 * parseHookName('--slds-c-badge-color-foreground')
 * // Returns:
 * // {
 * //   component: 'badge',
 * //   variant: null,
 * //   element: null,
 * //   category: 'color',
 * //   property: 'foreground',
 * //   attribute: null,
 * //   state: null
 * // }
 */
export function parseHookName(hookName: string): ParsedHookName {
  // Remove the --slds-c- prefix
  const withoutPrefix = hookName.replace(/^--slds-c-/, '');
  const parts = withoutPrefix.split('-');
 
  // Find category (required for full parsing)
  const { index: categoryIndex, category } = findCategoryIndex(parts);
 
  if (category === null) {
    // No category found - return minimal parsing
    return {
      component: parts[0] || null,
      variant: null,
      element: null,
      category: null,
      property: null,
      attribute: null,
      state: null,
    };
  }
 
  // Extract component and element (everything before category)
  const beforeCategory = parts.slice(0, categoryIndex);
  const component = beforeCategory[0] || null;
  const element = beforeCategory.length > 1 ? beforeCategory.slice(1).join('-') : null;
 
  // Find state (usually at the end)
  const { index: stateIndex, state } = findState(parts);
 
  // Find attribute (before state if present)
  const attributeSearchEnd = stateIndex >= 0 ? stateIndex : parts.length;
  const { index: attributeIndex, attribute } = findAttribute(parts, attributeSearchEnd);
 
  // Extract property
  const property = extractProperty(parts, categoryIndex, category, attributeIndex, stateIndex);
 
  return {
    component,
    variant: null, // Would need component registry to determine variants
    element,
    category,
    property,
    attribute,
    state,
  };
}
 
/**
 * Check if a CSS property name is a component hook.
 */
export function isComponentHook(property: string): boolean {
  return COMPONENT_HOOK_REGEX.test(property);
}