All files / packages/design-system-2/scripts/plugins postcss-lbc-theme.js

88.52% Statements 54/61
80.64% Branches 25/31
92.3% Functions 12/13
87.75% Lines 43/49

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                                                                    1x 31x 23x 29x                 1x 25x 25x 26x 23x   3x   2x                                     160x         1x 7x 7x   5x                 2x           1x                               1x 8x 263x   7x   7x 7x           1x     13x       25x 23x     20x   8x 8x         7x       7x 9x 7x     7x 5x       5x   5x 7x   7x     5x         1x      
/**
 * postcss-lbc-theme
 *
 * Transforms theme-layer CSS for Lightning Base Component (LBC) compatibility
 * in synthetic shadow DOM environments (RFC 1157).
 *
 * For every hook-only rule inside `@layer theme` whose selector maps to a known
 * LBC component, the plugin generates three rules from one:
 *
 *   1. Original rule    — kept for Light DOM / non-LBC consumers
 *   2. Host rule        — duplicates hook assignments onto the LBC host element
 *   3. Reset rule       — sets every hook to `unset` on the inner element so it
 *                          inherits from the host (root selectors only)
 *
 * Hook-only rule: every declaration's prop starts with `--slds-c-` (component hook
 * assignments). Values may reference other scopes (`--slds-g-*`, `--slds-s-*`)
 * but only the prop name matters for detection.
 *
 * Root selector: the mapped SLDS class is the outermost simple selector with no
 * descendant combinator following. `.slds-button`, `.slds-button:hover` are roots.
 * `.slds-card .slds-card__header` is NOT a root (sub-element).
 *
 * Sub-element rules get the root class replaced with the host element but no reset
 * is generated — the parent reset already covers inheritance.
 */
 
import LBC_SELECTOR_MAP from './lbc-selector-map.js';
 
/**
 * Returns true if every declaration in a rule is a `--slds-c-*` component hook assignment.
 *
 * @param {import('postcss').Rule} rule
 * @returns {boolean}
 */
const isHookOnlyRule = (rule) => {
  const declarations = rule.nodes?.filter((n) => n.type === 'decl') ?? [];
  if (declarations.length === 0) return false;
  return declarations.every((decl) => decl.prop.startsWith('--slds-c-'));
};
 
/**
 * Returns true if the rule is inside an `@layer theme` at-rule.
 *
 * @param {import('postcss').Rule} rule
 * @returns {boolean}
 */
const isInThemeLayer = (rule) => {
  let parent = rule.parent;
  while (parent) {
    if (parent.type === 'atrule' && parent.name === 'layer' && parent.params === 'theme') {
      return true;
    }
    parent = parent.parent;
  }
  return false;
};
 
/**
 * Attempts to match a selector against the LBC_SELECTOR_MAP.
 *
 * Returns an object with:
 *   - `sldsClass`:    the matched SLDS class from the map
 *   - `hostSelector`: the LBC host selector
 *   - `isRoot`:       true if the SLDS class is the outermost selector part (no descendant before it)
 *   - `fullHostSelector`: the complete rewritten selector
 *   - `resetSelector`:    the host + inner class selector for the reset rule (root only)
 *
 * Returns null if no match is found.
 *
 * @param {string} selector
 * @returns {{ sldsClass: string, hostSelector: string, isRoot: boolean, fullHostSelector: string, resetSelector: string | null } | null}
 */
/** Sorted map entries, longest key first (most specific match wins). */
const sortedEntries = [...LBC_SELECTOR_MAP.entries()].sort((a, b) => b[0].length - a[0].length);
 
/**
 * Builds a match result for a selector where the SLDS class appeared in the first part.
 */
const buildFirstPartMatch = (selector, parts, sldsClass, hostSelector) => {
  const fullHostSelector = selector.replace(sldsClass, hostSelector);
  if (parts.length === 1) {
    // Root: SLDS class is the only part (possibly with pseudo-classes)
    return {
      sldsClass,
      hostSelector,
      isRoot: true,
      fullHostSelector,
      resetSelector: `${hostSelector} ${selector}`,
    };
  }
  // Sub-element: root class with descendants after it — no reset
  return { sldsClass, hostSelector, isRoot: false, fullHostSelector, resetSelector: null };
};
 
/**
 * Finds a different mapped root class in the first selector part (sub-element under another root).
 */
const findAlternateRoot = (selector, parts, skipClass) => {
  for (const [rootClass, rootHost] of sortedEntries) {
    if (rootClass === skipClass) continue;
    if (parts[0].includes(rootClass)) {
      return {
        sldsClass: rootClass,
        hostSelector: rootHost,
        isRoot: false,
        fullHostSelector: selector.replace(rootClass, rootHost),
        resetSelector: null,
      };
    }
  }
  return null;
};
 
const matchSelector = (selector) => {
  for (const [sldsClass, hostSelector] of sortedEntries) {
    if (!selector.includes(sldsClass)) continue;
 
    const parts = selector.split(/\s+/);
 
    Eif (parts[0].includes(sldsClass)) {
      return buildFirstPartMatch(selector, parts, sldsClass, hostSelector);
    }
 
    return findAlternateRoot(selector, parts, sldsClass);
  }
 
  return null;
};
 
const plugin = () => ({
  postcssPlugin: 'postcss-lbc-theme',
 
  Rule(rule, { Rule }) {
    if (!isInThemeLayer(rule)) return;
    if (!isHookOnlyRule(rule)) return;
 
    // Skip rules that have already been processed (host/reset rules we generated)
    if (rule.__lbcProcessed) return;
 
    const match = matchSelector(rule.selector);
    if (!match) return;
 
    // 1. Original rule is kept as-is
 
    // 2. Host rule: duplicate hook assignments onto the LBC host element
    const hostRule = new Rule({
      selector: match.fullHostSelector,
      source: rule.source,
    });
    hostRule.__lbcProcessed = true;
    rule.nodes.forEach((node) => hostRule.append(node.clone()));
    rule.after(hostRule);
 
    // 3. Reset rule: only for root selectors
    if (match.isRoot && match.resetSelector) {
      const resetRule = new Rule({
        selector: match.resetSelector,
        source: rule.source,
      });
      resetRule.__lbcProcessed = true;
 
      rule.nodes
        .filter((n) => n.type === 'decl')
        .forEach((decl) => {
          resetRule.append(decl.clone({ value: 'unset' }));
        });
 
      hostRule.after(resetRule);
    }
  },
});
 
plugin.postcss = true;
 
export default plugin;