/** * * External dependencies */ import camelCase from 'lodash/camelCase'; import { calculate as getSelectorPriority, compare } from 'specificity'; import { CssTypes } from '@adobe/css-tools'; import type { CssAtRuleAST, CssDeclarationAST, CssStylesheetAST, } from '@adobe/css-tools'; import type { CssActiveElement, CssEditableProp, CssProp, CssPropName, CssReadonlyProp, CssSelectorPriority, Maybe, } from '@nab/types'; /** * Internal dependencies */ import { getLastRuleIndex } from '../../utils/ast'; import { expandProp, getGlobalProps, isPropName } from '../../utils/props'; type GroupedProps = { readonly own: Partial< Record< CssPropName, CssEditableProp > >; readonly shared: Partial< Record< CssPropName, CssProp > >; }; const EMPTY_PROPS: { readonly props: Partial< Record< CssPropName, CssProp > >; readonly propsRequiringImportant: ReadonlyArray< CssPropName >; } = { props: {}, propsRequiringImportant: [] }; export function getProps( ast: CssStylesheetAST, activeElement: Maybe< CssActiveElement > ): typeof EMPTY_PROPS { if ( ! activeElement ) { return EMPTY_PROPS; } const target = querySelector( '.nab-selected' ); if ( ! target ) { return EMPTY_PROPS; } const { selector } = activeElement; if ( ! querySelectorAll( selector ).includes( target ) ) { return EMPTY_PROPS; } const allProps = getAllProps( ast, selector, target ); const validProps = allProps.filter( ( p ) => ! isGlobalPropRequiringImportant( p ) ); const propsRequiringImportant = allProps .filter( isGlobalPropRequiringImportant ) .map( ( p ) => p.name ); const groupedProps: GroupedProps = { own: getTopPriorityProps( validProps.filter( isOwn ) ), shared: getTopPriorityProps( validProps.filter( isShared ) ), }; return { props: Object.values( mergeProps( groupedProps ) ) .map( setProperRule( ast, selector, target ) ) .reduce( ( r, p ) => ( { ...r, [ p.name ]: p } ), {} as Partial< Record< CssPropName, CssProp > > ), propsRequiringImportant, }; } // ======= // HELPERS // ======= function getAllProps( ast: CssStylesheetAST, selector: string, target: HTMLElement ): ReadonlyArray< CssProp > { const styleTag = document.getElementById( 'nab-css-style' ); return [ ...getGlobalProps( selector, target, styleTag ), ...getPropsFromAst( ast, selector, target ), ].map( ( p ): CssProp => 'readonly' === p.type ? p : { ...p, value: isSelector( selector, p.selectors ) ? p.value : '', base: isSelector( selector, p.selectors ) ? undefined : { value: p.value, priority: p.priority }, } ); } function getPropsFromAst( ast: CssStylesheetAST, selector: string, target: HTMLElement ): ReadonlyArray< CssProp > { const targetPriority = getSelectorPriority( selector ); return ast.stylesheet.rules .flatMap( ( rule, index ) => { if ( rule.type !== CssTypes.rule ) { return []; } const currentPriority = getRulePriority( target, rule ); if ( ! currentPriority ) { return []; } return rule.declarations .filter( ( d ): d is CssDeclarationAST => d.type === CssTypes.declaration ) .map( ( d ) => [ camelCase( d.property ), d ] as const ) .filter( ( pair ): pair is [ CssPropName, CssDeclarationAST ] => isPropName( pair[ 0 ] ) ) .map( ( [ name, d ] ): CssProp => { const value = d.value.replace( /\s*!important\s*/i, '' ); if ( ! isSelector( selector, rule.selectors ) && isImportant( d.value ) && isFirstGreater( currentPriority, targetPriority ) ) { const readonlyProp: CssReadonlyProp = { type: 'readonly', name, value, priority: currentPriority, selectors: rule.selectors, }; return readonlyProp; } const prop: CssProp = { type: isImportant( d.value ) ? 'important' : 'regular', name, value, base: undefined, ruleIndex: index, priority: currentPriority, selectors: rule.selectors, }; return prop; } ); } ) .flatMap( expandProp ); } function getTopPriorityProps< T extends CssProp >( inputProps: ReadonlyArray< T > ): Partial< Record< CssPropName, T > > { return inputProps.reduce( ( props, latter ) => { const former = props[ latter.name ]; if ( former === getTopPriorityProp( { latter, former } ) ) { return props; } return { ...props, [ latter.name ]: latter }; }, {} as Partial< Record< CssPropName, T > > ); } function getTopPriorityProp< TLatter extends CssProp, TFormer extends CssProp, >( { latter, former, }: { readonly latter: TLatter; readonly former: Maybe< TFormer >; } ): TLatter | TFormer { if ( ! former ) { return latter; } const isLatterGreaterOrEqual = isFirstGreaterOrEqual( latter.priority, former.priority ); if ( 'readonly' === former.type && 'readonly' === latter.type ) { return isLatterGreaterOrEqual ? latter : former; } if ( 'readonly' === former.type ) { return former; } if ( 'readonly' === latter.type ) { return latter; } if ( 'important' === former.type ) { return isLatterGreaterOrEqual && 'important' === latter.type ? latter : former; } return isLatterGreaterOrEqual || 'important' === latter.type ? latter : former; } function mergeProps( { own, shared, }: GroupedProps ): Partial< Record< CssPropName, CssProp > > { return Object.values( own ).reduce( ( res, current ) => { const { name } = current; const previous = res[ name ]; if ( ! previous ) { return { ...res, [ name ]: current }; } if ( 'readonly' === previous.type ) { return res; } const [ former, latter ] = sortProps( previous, current ); const best = getTopPriorityProp( { latter, former } ); return { ...res, [ name ]: { ...best, base: previous.base } }; }, shared as Record< CssPropName, CssProp > ); } const setProperRule = ( ast: CssStylesheetAST, selector: string, target: HTMLElement ) => ( prop: CssProp ): CssProp => { if ( isReadonly( prop ) ) { return prop; } if ( prop.selectors.length === 1 && prop.selectors[ 0 ] === selector && prop.ruleIndex >= 0 ) { return prop; } const lastRuleIndex = getLastRuleIndex( ast, selector ); const fallbackRuleIndex = ast.stylesheet.rules.length - 1; const ruleIndex = lastRuleIndex > prop.ruleIndex ? lastRuleIndex : fallbackRuleIndex; const rule = ast.stylesheet.rules[ ruleIndex ]; if ( ! rule ) { // This should never happen–we know rule exists. return { ...prop, type: 'readonly' }; } const priority = getRulePriority( target, rule ); const important = 'important' === prop.type || isFirstSmaller( priority, prop.priority ); return { ...prop, ruleIndex, type: important ? 'important' : prop.type, }; }; const getRulePriority = ( target: HTMLElement, rule?: CssAtRuleAST ) => rule?.type === CssTypes.rule ? rule.selectors .filter( ( s ) => ! hasIgnorablePseudoClass( s ) ) .filter( ( s ) => querySelectorAll( s ).includes( target ) ) .map( getSelectorPriority ) .reduce( ( max, p ) => maxPriority( p, max ), undefined as CssSelectorPriority | undefined ) : undefined; function maxPriority( s1: CssSelectorPriority, s2?: CssSelectorPriority ): CssSelectorPriority { return ! s2 || isFirstGreaterOrEqual( s1, s2 ) ? s1 : s2; } function isFirstGreaterOrEqual( s1: CssSelectorPriority, s2?: CssSelectorPriority ): boolean { return ! s2 || 0 <= compare( s1, s2 ); } function isFirstGreater( s1: CssSelectorPriority, s2?: CssSelectorPriority ): boolean { return ! s2 || 0 < compare( s1, s2 ); } function isFirstSmaller( s1: CssSelectorPriority | undefined, s2: CssSelectorPriority ): boolean { return ! s1 || compare( s1, s2 ) < 0; } const IGNORABLE_PSEUDO_CLASS = [ ':hover', ':active', ':focus', ':focus-visible', ':focus-within', ':visited', ':target', ':before', ':after', ]; function hasIgnorablePseudoClass( selector: string ): boolean { return IGNORABLE_PSEUDO_CLASS.some( ( c ) => selector.includes( c ) ); } function querySelector( selector: string ): Maybe< HTMLElement > { return querySelectorAll( selector )[ 0 ]; } function isImportant( value: string ): boolean { return /!important\b/i.test( value ); } function isSelector( selector: string, selectors: ReadonlyArray< string > ) { return selectors.length === 1 && selectors[ 0 ] === selector; } const isOwn = ( p: CssProp ): p is CssEditableProp => ! isReadonly( p ) && ! p.base; const isShared = ( p: CssProp ): boolean => isReadonly( p ) || !! p.base; const isReadonly = ( p: CssProp ): p is CssReadonlyProp => p.type === 'readonly'; const sortProps = ( p1: CssEditableProp, p2: CssEditableProp ): [ CssEditableProp, CssEditableProp ] => p1.ruleIndex < p2.ruleIndex ? [ p1, p2 ] : [ p2, p1 ]; function querySelectorAll( selector: string ): ReadonlyArray< HTMLElement > { try { return Array.from( document.querySelectorAll( selector ) ); } catch ( _ ) { return []; } } const isGlobalPropRequiringImportant = ( p: CssProp ) => p.selectors[ 0 ] === '.nelio-ab-testing--global-prop' && p.type === 'important';