@stylable/core
Version:
CSS for Components
461 lines (413 loc) • 16 kB
text/typescript
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, '_')
)}`;
}
}