import postcss from 'postcss'; import { Diagnostics } from './diagnostics'; import { evalDeclarationValue } from './functions'; import { nativePseudoClasses } from './native-reserved-lists'; import { SelectorAstNode } from './selector-utils'; import { StateResult, systemValidators } from './state-validators'; import { ClassSymbol, ElementSymbol, SRule, StylableMeta, StylableSymbol, } from './stylable-processor'; import { StylableResolver } from './stylable-resolver'; import { isValidClassName } from './stylable-utils'; import { groupValues, listOptions, MappedStates } from './stylable-value-parsers'; import { valueMapping } from './stylable-value-parsers'; import { ParsedValue, StateParsedValue } from './types'; import { stripQuotation } from './utils'; const isVendorPrefixed = require('is-vendor-prefixed'); const postcssValueParser = require('postcss-value-parser'); const { hasOwnProperty } = Object.prototype; export const stateMiddleDelimiter = '-'; export const booleanStateDelimiter = '--'; export const stateWithParamDelimiter = booleanStateDelimiter + stateMiddleDelimiter; export const stateErrors = { UNKNOWN_STATE_USAGE: (name: string) => `unknown pseudo-state "${name}"`, UNKNOWN_STATE_TYPE: (name: string, type: string) => `pseudo-state "${name}" defined with unknown type: "${type}"`, TOO_MANY_STATE_TYPES: (name: string, types: string[]) => `pseudo-state "${name}(${types.join(', ')})" definition must be of a single type`, NO_STATE_TYPE_GIVEN: (name: string) => `pseudo-state "${name}" expected a definition of a single type, but received none`, TOO_MANY_ARGS_IN_VALIDATOR: (name: string, validator: string, args: string[]) => `pseudo-state "${name}" expected "${validator}" validator to receive a single argument, but it received "${args.join( ', ' )}"`, STATE_STARTS_WITH_HYPHEN: (name: string) => `state "${name}" declaration cannot begin with a "${stateMiddleDelimiter}" chararcter`, }; // PROCESS export function processPseudoStates( value: string, decl: postcss.Declaration, diagnostics: Diagnostics ) { const mappedStates: MappedStates = {}; const ast = postcssValueParser(value); const statesSplitByComma = groupValues(ast.nodes); statesSplitByComma.forEach((workingState: ParsedValue[]) => { const [stateDefinition, ...stateDefault] = workingState; if (stateDefinition.value.startsWith('-')) { diagnostics.error(decl, stateErrors.STATE_STARTS_WITH_HYPHEN(stateDefinition.value), { word: stateDefinition.value, }); } if (stateDefinition.type === 'function') { resolveStateType(stateDefinition, mappedStates, stateDefault, diagnostics, decl); } else if (stateDefinition.type === 'word') { resolveBooleanState(mappedStates, stateDefinition); } else { // TODO: Invalid state, edge case needs warning } }); return mappedStates; } function resolveStateType( stateDefinition: ParsedValue, mappedStates: MappedStates, stateDefault: ParsedValue[], diagnostics: Diagnostics, decl: postcss.Declaration ) { if (stateDefinition.type === 'function' && stateDefinition.nodes.length === 0) { resolveBooleanState(mappedStates, stateDefinition); diagnostics.warn(decl, stateErrors.NO_STATE_TYPE_GIVEN(stateDefinition.value), { word: decl.value, }); return; } if (stateDefinition.nodes.length > 1) { diagnostics.warn( decl, stateErrors.TOO_MANY_STATE_TYPES(stateDefinition.value, listOptions(stateDefinition)), { word: decl.value } ); } const paramType = stateDefinition.nodes[0]; const stateType: StateParsedValue = { type: stateDefinition.nodes[0].value, arguments: [], defaultValue: postcssValueParser.stringify(stateDefault).trim(), }; if (isCustomMapping(stateDefinition)) { mappedStates[stateDefinition.value] = stateType.type.trim().replace(/\\["']/g, '"'); } else if (typeof stateType === 'object' && stateType.type === 'boolean') { resolveBooleanState(mappedStates, stateDefinition); return; } else if (paramType.type === 'function' && stateType.type in systemValidators) { if (paramType.nodes.length > 0) { resolveArguments(paramType, stateType, stateDefinition.value, diagnostics, decl); } mappedStates[stateDefinition.value] = stateType; } else if (stateType.type in systemValidators) { mappedStates[stateDefinition.value] = stateType; } else { diagnostics.warn( decl, stateErrors.UNKNOWN_STATE_TYPE(stateDefinition.value, paramType.value), { word: paramType.value } ); } } function resolveArguments( paramType: ParsedValue, stateType: StateParsedValue, name: string, diagnostics: Diagnostics, decl: postcss.Declaration ) { const separatedByComma = groupValues(paramType.nodes); separatedByComma.forEach((group) => { const validator = group[0]; if (validator.type === 'function') { const args = listOptions(validator); if (args.length > 1) { diagnostics.warn( decl, stateErrors.TOO_MANY_ARGS_IN_VALIDATOR(name, validator.value, args), { word: decl.value } ); } else { stateType.arguments.push({ name: validator.value, args, }); } } else if (validator.type === 'string' || validator.type === 'word') { stateType.arguments.push(validator.value); } }); } function isCustomMapping(stateDefinition: ParsedValue) { return stateDefinition.nodes.length === 1 && stateDefinition.nodes[0].type === 'string'; } function resolveBooleanState(mappedStates: MappedStates, stateDefinition: ParsedValue) { const currentState = mappedStates[stateDefinition.type]; if (!currentState) { mappedStates[stateDefinition.value] = null; // add boolean state } else { // TODO: warn with such name already exists } } // TRANSFORM export function validateStateDefinition( decl: postcss.Declaration, meta: StylableMeta, resolver: StylableResolver, diagnostics: Diagnostics ) { if (decl.parent && decl.parent.type !== 'root') { const container = decl.parent; if (container.type !== 'atrule') { const sRule: SRule = container as SRule; if (sRule.selectorAst.nodes && sRule.selectorAst.nodes.length === 1) { const singleSelectorAst = sRule.selectorAst.nodes[0]; const selectorChunk = singleSelectorAst.nodes; if (selectorChunk.length === 1 && selectorChunk[0].type === 'class') { const className = selectorChunk[0].name; const classMeta = meta.classes[className]; const states = classMeta[valueMapping.states]; if (classMeta && classMeta._kind === 'class' && states) { for (const stateName in states) { // TODO: Sort out types const state = states[stateName]; if (state && typeof state === 'object') { const res = validateStateArgument( state, meta, state.defaultValue || '', resolver, diagnostics, sRule, true, !!state.defaultValue ); if (res.errors) { res.errors.unshift( `pseudo-state "${stateName}" default value "${state.defaultValue}" failed validation:` ); diagnostics.warn(decl, res.errors.join('\n'), { word: decl.value, }); } } } } else { // TODO: error state on non-class } } } } } } export function validateStateArgument( stateAst: StateParsedValue, meta: StylableMeta, value: string, resolver: StylableResolver, diagnostics: Diagnostics, rule?: postcss.Rule, validateDefinition?: boolean, validateValue = true ) { const resolvedValidations: StateResult = { res: resolveParam(meta, resolver, diagnostics, rule, value || stateAst.defaultValue), errors: null, }; const { type: paramType } = stateAst; const validator = systemValidators[paramType]; try { if (resolvedValidations.res || validateDefinition) { const { errors } = validator.validate( resolvedValidations.res, stateAst.arguments, resolveParam.bind(null, meta, resolver, diagnostics, rule), !!validateDefinition, validateValue ); resolvedValidations.errors = errors; } } catch (error) { // TODO: warn about validation throwing exception } return resolvedValidations; } export function transformPseudoStateSelector( meta: StylableMeta, node: SelectorAstNode, name: string, symbol: StylableSymbol | null, origin: StylableMeta, originSymbol: ClassSymbol | ElementSymbol, resolver: StylableResolver, diagnostics: Diagnostics, rule?: postcss.Rule ) { let current = meta; let currentSymbol = symbol; if (originSymbol && symbol !== originSymbol) { current = origin; currentSymbol = originSymbol; } let found = false; while (current && currentSymbol) { if (currentSymbol._kind === 'class' || currentSymbol._kind === 'element') { const states = currentSymbol[valueMapping.states]; const extend = currentSymbol[valueMapping.extends]; const alias = currentSymbol.alias; if (states && hasOwnProperty.call(states, name)) { found = true; setStateToNode( states, meta, name, node, current.namespace, resolver, diagnostics, rule ); break; } else if (extend) { if ( current.mappedSymbols[extend.name] && current.mappedSymbols[extend.name]._kind !== 'import' ) { const nextCurrentSymbol = current.mappedSymbols[extend.name]; if (currentSymbol === nextCurrentSymbol) { break; } currentSymbol = nextCurrentSymbol; } else { const next = resolver.resolve(extend); if (next && next.meta) { currentSymbol = next.symbol; current = next.meta; } else { break; } } } else if (alias) { const next = resolver.resolve(alias); if (next && next.meta) { currentSymbol = next.symbol; current = next.meta; } else { break; } } else { break; } } else { break; } } if (!found && rule) { if (!nativePseudoClasses.includes(name) && !isVendorPrefixed(name)) { diagnostics.warn(rule, stateErrors.UNKNOWN_STATE_USAGE(name), { word: name }); } } return meta; } export function setStateToNode( states: MappedStates, meta: StylableMeta, name: string, node: SelectorAstNode, namespace: string, resolver: StylableResolver, diagnostics: Diagnostics, rule?: postcss.Rule ) { const stateDef = states[name]; if (stateDef === null) { node.type = 'class'; node.name = createBooleanStateClassName(name, namespace); } else if (typeof stateDef === 'string') { node.type = 'invalid'; // simply concat global mapped selector - ToDo: maybe change to 'selector' node.value = stateDef; } else if (typeof stateDef === 'object') { resolveStateValue(meta, resolver, diagnostics, rule, node, stateDef, name, namespace); } } function resolveStateValue( meta: StylableMeta, resolver: StylableResolver, diagnostics: Diagnostics, rule: postcss.Rule | undefined, node: SelectorAstNode, stateDef: StateParsedValue, name: string, namespace: string ) { let actualParam = resolveParam( meta, resolver, diagnostics, rule, node.content || stateDef.defaultValue ); const validator = systemValidators[stateDef.type]; let stateParamOutput; try { stateParamOutput = validator.validate( actualParam, stateDef.arguments, resolveParam.bind(null, meta, resolver, diagnostics, rule), false, true ); } catch (e) { // TODO: warn about validation throwing exception } if (stateParamOutput !== undefined) { if (stateParamOutput.res !== actualParam) { actualParam = stateParamOutput.res; } if (rule && stateParamOutput.errors) { stateParamOutput.errors.unshift( `pseudo-state "${name}" with parameter "${actualParam}" failed validation:` ); diagnostics.warn(rule, stateParamOutput.errors.join('\n'), { word: actualParam }); } } const strippedParam = stripQuotation(actualParam); if (isValidClassName(strippedParam)) { node.type = 'class'; node.name = createStateWithParamClassName(name, namespace, strippedParam); } else { node.type = 'attribute'; node.content = createAttributeState(name, namespace, strippedParam); } } function resolveParam( meta: StylableMeta, resolver: StylableResolver, diagnostics: Diagnostics, rule?: postcss.Rule, nodeContent?: string ) { const defaultStringValue = ''; const param = nodeContent || defaultStringValue; return evalDeclarationValue(resolver, param, meta, rule, undefined, undefined, diagnostics); } export function createBooleanStateClassName(stateName: string, namespace: string) { return `${namespace}${booleanStateDelimiter}${stateName}`; } export function createStateWithParamClassName(stateName: string, namespace: string, param: string) { return `${namespace}${stateWithParamDelimiter}${stateName}${resolveStateParam(param)}`; } export function createAttributeState(stateName: string, namespace: string, param: string) { return `class~="${createStateWithParamClassName(stateName, namespace, param)}"`; } export function resolveStateParam(param: string) { if (isValidClassName(param)) { return `${stateMiddleDelimiter}${param.length}${stateMiddleDelimiter}${param}`; } else { return `${stateMiddleDelimiter}${param.length}${stateMiddleDelimiter}${stripQuotation( JSON.stringify(param).replace(/\s/gm, '_') )}`; } }