@stylable/core
Version:
CSS for Components
805 lines (737 loc) • 29.9 kB
text/typescript
import hash from 'murmurhash';
import path from 'path';
import postcss from 'postcss';
import postcssValueParser from 'postcss-value-parser';
import { Diagnostics } from './diagnostics';
import {
createSimpleSelectorChecker,
isChildOfAtRule,
isCompRoot,
isRootValid,
parseSelector,
SelectorAstNode,
traverseNode,
} from './selector-utils';
import { processDeclarationUrls } from './stylable-assets';
import {
ClassSymbol,
CSSVarSymbol,
ElementSymbol,
Imported,
ImportSymbol,
RefedMixin,
StylableDirectives,
StylableMeta,
VarSymbol,
} from './stylable-meta';
import {
CUSTOM_SELECTOR_RE,
expandCustomSelectors,
getAlias,
isCSSVarProp,
scopeSelector,
} from './stylable-utils';
import {
rootValueMapping,
SBTypesParsers,
stValuesMap,
validateAllowedNodesUntil,
valueMapping,
} from './stylable-value-parsers';
import { deprecated, filename2varname, stripQuotation } from './utils';
export * from './stylable-meta'; /* TEMP EXPORT */
const parseNamed = SBTypesParsers[valueMapping.named];
const parseMixin = SBTypesParsers[valueMapping.mixin];
const parseStates = SBTypesParsers[valueMapping.states];
const parseGlobal = SBTypesParsers[valueMapping.global];
const parseExtends = SBTypesParsers[valueMapping.extends];
export const processorWarnings = {
UNSCOPED_CLASS(name: string) {
return `unscoped class "${name}" will affect all elements of the same type in the document`;
},
UNSCOPED_ELEMENT(name: string) {
return `unscoped element "${name}" will affect all elements of the same type in the document`;
},
FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(name: string) {
return `cannot define "${name}" inside a complex selector`;
},
ROOT_AFTER_SPACING() {
return '".root" class cannot be used after native elements or selectors external to the stylesheet';
},
DEFAULT_IMPORT_IS_LOWER_CASE() {
return 'Default import of a Stylable stylesheet must start with an upper-case letter';
},
ILLEGAL_PROP_IN_IMPORT(propName: string) {
return `"${propName}" css attribute cannot be used inside ${rootValueMapping.import} block`;
},
STATE_DEFINITION_IN_ELEMENT() {
return 'cannot define pseudo states inside element selectors';
},
STATE_DEFINITION_IN_COMPLEX() {
return 'cannot define pseudo states inside complex selectors';
},
REDECLARE_SYMBOL(name: string) {
return `redeclare symbol "${name}"`;
},
CANNOT_RESOLVE_EXTEND(name: string) {
return `cannot resolve '${valueMapping.extends}' type for '${name}'`;
},
CANNOT_EXTEND_IN_COMPLEX() {
return `cannot define "${valueMapping.extends}" inside a complex selector`;
},
UNKNOWN_MIXIN(name: string) {
return `unknown mixin: "${name}"`;
},
OVERRIDE_MIXIN() {
return `override mixin on same rule`;
},
OVERRIDE_TYPED_RULE(key: string, name: string) {
return `override "${key}" on typed rule "${name}"`;
},
FROM_PROP_MISSING_IN_IMPORT() {
return `"${valueMapping.from}" is missing in ${rootValueMapping.import} block`;
},
INVALID_NAMESPACE_DEF() {
return 'invalid @namespace';
},
EMPTY_NAMESPACE_DEF() {
return '@namespace must contain at least one character or digit';
},
EMPTY_IMPORT_FROM() {
return '"-st-from" cannot be empty';
},
MULTIPLE_FROM_IN_IMPORT() {
return `cannot define multiple "${valueMapping.from}" declarations in a single import`;
},
NO_VARS_DEF_IN_ST_SCOPE() {
return `cannot define "${rootValueMapping.vars}" inside of "@st-scope"`;
},
NO_IMPORT_IN_ST_SCOPE() {
return `cannot use "${rootValueMapping.import}" inside of "@st-scope"`;
},
NO_KEYFRAMES_IN_ST_SCOPE() {
return `cannot use "@keyframes" inside of "@st-scope"`;
},
SCOPE_PARAM_NOT_SIMPLE_SELECTOR(selector: string) {
return `"@st-scope" must receive a simple selector, but instead got: "${selector}"`;
},
MISSING_SCOPING_PARAM() {
return '"@st-scope" must receive a simple selector or stylesheet "root" as its scoping parameter';
},
ILLEGAL_GLOBAL_CSS_VAR(name: string) {
return `"@st-global-custom-property" received the value "${name}", but it must begin with "--" (double-dash)`;
},
GLOBAL_CSS_VAR_MISSING_COMMA(name: string) {
return `"@st-global-custom-property" received the value "${name}", but its values must be comma separated`;
},
ILLEGAL_CSS_VAR_USE(name: string) {
return `a custom css property must begin with "--" (double-dash), but received "${name}"`;
},
ILLEGAL_CSS_VAR_ARGS(name: string) {
return `css variable "${name}" usage (var()) must receive comma separated values`;
},
};
export class StylableProcessor {
protected meta!: StylableMeta;
constructor(
protected diagnostics = new Diagnostics(),
private resolveNamespace = processNamespace
) {}
public process(root: postcss.Root): StylableMeta {
this.meta = new StylableMeta(root, this.diagnostics);
this.handleAtRules(root);
const stubs = this.insertCustomSelectorsStubs();
root.walkRules((rule) => {
if (!isChildOfAtRule(rule, 'keyframes')) {
this.handleCustomSelectors(rule);
this.handleRule(rule as SRule, isChildOfAtRule(rule, rootValueMapping.stScope));
}
});
root.walkDecls((decl) => {
if (stValuesMap[decl.prop]) {
this.handleDirectives(decl.parent as SRule, decl);
} else if (isCSSVarProp(decl.prop)) {
this.addCSSVarFromProp(decl);
}
if (decl.value.includes('var(')) {
this.handleCSSVarUse(decl);
}
processDeclarationUrls(
decl,
(node) => {
this.meta.urls.push(node.url!);
},
false
);
});
this.meta.scopes.forEach((atRule) => {
const scopingRule = postcss.rule({ selector: atRule.params }) as SRule;
this.handleRule(scopingRule, true);
validateScopingSelector(atRule, scopingRule, this.diagnostics);
if (scopingRule.selector) {
atRule.walkRules((rule) => {
rule.replaceWith(
rule.clone({
selector: scopeSelector(scopingRule.selector, rule.selector, false)
.selector,
})
);
});
}
atRule.replaceWith(atRule.nodes || []);
});
stubs.forEach((s) => s && s.remove());
return this.meta;
}
public insertCustomSelectorsStubs() {
return Object.keys(this.meta.customSelectors).map((selector) => {
if (this.meta.customSelectors[selector]) {
const rule = postcss.rule({ selector });
this.meta.ast.append(rule);
return rule;
}
return null;
});
}
public handleCustomSelectors(rule: postcss.Rule) {
expandCustomSelectors(rule, this.meta.customSelectors, this.meta.diagnostics);
}
protected handleAtRules(root: postcss.Root) {
let namespace = '';
const toRemove: postcss.Node[] = [];
root.walkAtRules((atRule) => {
switch (atRule.name) {
case 'namespace': {
const match = atRule.params.match(/["'](.*?)['"]/);
if (match) {
if (match[1].trim()) {
namespace = match[1];
} else {
this.diagnostics.error(atRule, processorWarnings.EMPTY_NAMESPACE_DEF());
}
toRemove.push(atRule);
} else {
this.diagnostics.error(atRule, processorWarnings.INVALID_NAMESPACE_DEF());
}
break;
}
case 'keyframes':
if (!isChildOfAtRule(atRule, rootValueMapping.stScope)) {
this.meta.keyframes.push(atRule);
} else {
this.diagnostics.warn(atRule, processorWarnings.NO_KEYFRAMES_IN_ST_SCOPE());
}
break;
case 'custom-selector': {
const params = atRule.params.split(/\s/);
const customName = params.shift();
toRemove.push(atRule);
if (customName && customName.match(CUSTOM_SELECTOR_RE)) {
this.meta.customSelectors[customName] = atRule.params
.replace(customName, '')
.trim();
} else {
// TODO: add warn there are two types one is not valid name and the other is empty name.
}
break;
}
case 'st-scope':
this.meta.scopes.push(atRule);
break;
case 'st-global-custom-property': {
const cssVars = atRule.params.split(',');
if (atRule.params.trim().split(/\s+/g).length > cssVars.length) {
this.diagnostics.warn(
atRule,
processorWarnings.GLOBAL_CSS_VAR_MISSING_COMMA(atRule.params),
{ word: atRule.params }
);
break;
}
for (const entry of cssVars) {
const cssVar = entry.trim();
if (isCSSVarProp(cssVar)) {
if (!this.meta.cssVars[cssVar]) {
this.meta.cssVars[cssVar] = {
_kind: 'cssVar',
name: cssVar,
global: true,
};
this.meta.mappedSymbols[cssVar] = this.meta.cssVars[cssVar];
}
} else {
this.diagnostics.warn(
atRule,
processorWarnings.ILLEGAL_GLOBAL_CSS_VAR(cssVar),
{ word: cssVar }
);
}
}
toRemove.push(atRule);
break;
}
}
});
toRemove.forEach((node) => node.remove());
namespace = namespace || filename2varname(path.basename(this.meta.source)) || 's';
this.meta.namespace = this.handleNamespaceReference(namespace);
}
private handleNamespaceReference(namespace: string): string {
let pathToSource: string | undefined;
this.meta.ast.walkComments((comment) => {
if (comment.text.includes('st-namespace-reference')) {
const namespaceReferenceParts = comment.text.split('=');
pathToSource = stripQuotation(
namespaceReferenceParts[namespaceReferenceParts.length - 1]
);
return false;
}
return undefined;
});
return this.resolveNamespace(
namespace,
pathToSource
? path.resolve(path.dirname(this.meta.source), pathToSource)
: this.meta.source
);
}
protected handleRule(rule: SRule, inStScope = false) {
rule.selectorAst = parseSelector(rule.selector);
const checker = createSimpleSelectorChecker();
const validRoot = isRootValid(rule.selectorAst, 'root');
let locallyScoped = false;
traverseNode(rule.selectorAst, (node, _index, _nodes) => {
if (!checker(node)) {
rule.isSimpleSelector = false;
}
const { name, type } = node;
if (type === 'pseudo-class') {
if (name === 'import') {
if (rule.selector === rootValueMapping.import) {
if (isChildOfAtRule(rule, rootValueMapping.stScope)) {
this.diagnostics.warn(rule, processorWarnings.NO_IMPORT_IN_ST_SCOPE());
rule.remove();
return false;
}
const _import = this.handleImport(rule);
this.meta.imports.push(_import);
this.addImportSymbols(_import);
return false;
} else {
this.diagnostics.warn(
rule,
processorWarnings.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(
rootValueMapping.import
)
);
}
} else if (name === 'vars') {
if (rule.selector === rootValueMapping.vars) {
if (isChildOfAtRule(rule, rootValueMapping.stScope)) {
this.diagnostics.warn(
rule,
processorWarnings.NO_VARS_DEF_IN_ST_SCOPE()
);
rule.remove();
return false;
}
this.addVarSymbols(rule);
return false;
} else {
this.diagnostics.warn(
rule,
processorWarnings.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(
rootValueMapping.vars
)
);
}
}
} else if (type === 'class') {
this.addClassSymbolOnce(name, rule);
if (this.meta.classes[name]) {
if (!this.meta.classes[name].alias) {
locallyScoped = true;
} else if (locallyScoped === false && !inStScope) {
this.diagnostics.warn(rule, processorWarnings.UNSCOPED_CLASS(name), {
word: name,
});
}
}
} else if (type === 'element') {
this.addElementSymbolOnce(name, rule);
if (locallyScoped === false && !inStScope) {
this.diagnostics.warn(rule, processorWarnings.UNSCOPED_ELEMENT(name), {
word: name,
});
}
} else if (type === 'nested-pseudo-class' && name === 'global') {
return true;
}
return void 0;
});
if (rule.isSimpleSelector !== false) {
rule.isSimpleSelector = true;
rule.selectorType = rule.selector.match(/^\./) ? 'class' : 'element';
} else {
rule.selectorType = 'complex';
}
if (!validRoot) {
this.diagnostics.warn(rule, processorWarnings.ROOT_AFTER_SPACING());
}
}
protected checkRedeclareSymbol(symbolName: string, node: postcss.Node) {
const symbol = this.meta.mappedSymbols[symbolName];
if (symbol) {
this.diagnostics.warn(node, processorWarnings.REDECLARE_SYMBOL(symbolName), {
word: symbolName,
});
}
}
protected addElementSymbolOnce(name: string, rule: postcss.Rule) {
if (isCompRoot(name) && !this.meta.elements[name]) {
let alias = this.meta.mappedSymbols[name] as ImportSymbol | undefined;
if (alias && alias._kind !== 'import') {
this.checkRedeclareSymbol(name, rule);
alias = undefined;
}
this.meta.elements[name] = this.meta.mappedSymbols[name] = {
_kind: 'element',
name,
alias,
};
this.meta.simpleSelectors[name] = {
node: rule,
symbol: this.meta.elements[name],
};
}
}
protected addClassSymbolOnce(name: string, rule: postcss.Rule) {
if (!this.meta.classes[name]) {
let alias = this.meta.mappedSymbols[name] as ImportSymbol | undefined;
if (alias && alias._kind !== 'import') {
this.checkRedeclareSymbol(name, rule);
alias = undefined;
}
this.meta.classes[name] = this.meta.mappedSymbols[name] = {
_kind: 'class',
name,
alias,
};
this.meta.simpleSelectors[name] = {
node: rule,
symbol: this.meta.mappedSymbols[name] as ClassSymbol,
};
} else if (name === this.meta.root && !this.meta.simpleSelectors[name]) {
// special handling for registering "root" node comments
this.meta.simpleSelectors[name] = {
node: rule,
symbol: this.meta.classes[name],
};
}
}
protected addImportSymbols(importDef: Imported) {
if (importDef.defaultExport) {
this.checkRedeclareSymbol(importDef.defaultExport, importDef.rule);
this.meta.mappedSymbols[importDef.defaultExport] = {
_kind: 'import',
type: 'default',
name: 'default',
import: importDef,
context: path.dirname(this.meta.source),
};
}
Object.keys(importDef.named).forEach((name) => {
this.checkRedeclareSymbol(name, importDef.rule);
this.meta.mappedSymbols[name] = {
_kind: 'import',
type: 'named',
name: importDef.named[name],
import: importDef,
context: path.dirname(this.meta.source),
};
});
}
protected addVarSymbols(rule: postcss.Rule) {
rule.walkDecls((decl) => {
this.checkRedeclareSymbol(decl.prop, decl);
let type = null;
const prev = decl.prev() as postcss.Comment;
if (prev && prev.type === 'comment') {
const typeMatch = prev.text.match(/^ (.+)$/);
if (typeMatch) {
type = typeMatch[1];
}
}
const varSymbol: VarSymbol = {
_kind: 'var',
name: decl.prop,
value: '',
text: decl.value,
node: decl,
valueType: type,
};
this.meta.vars.push(varSymbol);
this.meta.mappedSymbols[decl.prop] = varSymbol;
});
rule.remove();
}
protected handleCSSVarUse(decl: postcss.Declaration) {
const parsed = postcssValueParser(decl.value);
parsed.walk((node) => {
if (node.type === 'function' && node.value === 'var' && node.nodes) {
const varName = node.nodes[0];
if (!validateAllowedNodesUntil(node, 1)) {
const args = postcssValueParser.stringify(node.nodes);
this.diagnostics.warn(decl, processorWarnings.ILLEGAL_CSS_VAR_ARGS(args), {
word: args,
});
}
this.addCSSVar(postcssValueParser.stringify(varName).trim(), decl);
}
});
}
protected addCSSVarFromProp(decl: postcss.Declaration) {
const varName = decl.prop.trim();
this.addCSSVar(varName, decl);
}
protected addCSSVar(varName: string, decl: postcss.Declaration) {
if (isCSSVarProp(varName)) {
if (!this.meta.cssVars[varName]) {
const cssVarSymbol: CSSVarSymbol = {
_kind: 'cssVar',
name: varName,
};
this.meta.cssVars[varName] = cssVarSymbol;
if (!this.meta.mappedSymbols[varName]) {
this.meta.mappedSymbols[varName] = cssVarSymbol;
}
}
} else {
this.diagnostics.warn(decl, processorWarnings.ILLEGAL_CSS_VAR_USE(varName), {
word: varName,
});
}
}
protected handleDirectives(rule: SRule, decl: postcss.Declaration) {
if (decl.prop === valueMapping.states) {
if (rule.isSimpleSelector && rule.selectorType !== 'element') {
this.extendTypedRule(
decl,
rule.selector,
valueMapping.states,
parseStates(decl.value, decl, this.diagnostics)
);
} else {
if (rule.selectorType === 'element') {
this.diagnostics.warn(decl, processorWarnings.STATE_DEFINITION_IN_ELEMENT());
} else {
this.diagnostics.warn(decl, processorWarnings.STATE_DEFINITION_IN_COMPLEX());
}
}
} else if (decl.prop === valueMapping.extends) {
if (rule.isSimpleSelector) {
const parsed = parseExtends(decl.value);
const symbolName = parsed.types[0] && parsed.types[0].symbolName;
const extendsRefSymbol = this.meta.mappedSymbols[symbolName];
if (
(extendsRefSymbol &&
(extendsRefSymbol._kind === 'import' ||
extendsRefSymbol._kind === 'class' ||
extendsRefSymbol._kind === 'element')) ||
decl.value === this.meta.root
) {
this.extendTypedRule(
decl,
rule.selector,
valueMapping.extends,
getAlias(extendsRefSymbol) || extendsRefSymbol
);
} else {
this.diagnostics.warn(
decl,
processorWarnings.CANNOT_RESOLVE_EXTEND(decl.value),
{ word: decl.value }
);
}
} else {
this.diagnostics.warn(decl, processorWarnings.CANNOT_EXTEND_IN_COMPLEX());
}
} else if (decl.prop === valueMapping.mixin) {
const mixins: RefedMixin[] = [];
parseMixin(
decl,
(type) => {
const mixinRefSymbol = this.meta.mappedSymbols[type];
if (
mixinRefSymbol &&
mixinRefSymbol._kind === 'import' &&
!mixinRefSymbol.import.from.match(/.css$/)
) {
return 'args';
}
return 'named';
},
this.diagnostics
).forEach((mixin) => {
const mixinRefSymbol = this.meta.mappedSymbols[mixin.type];
if (
mixinRefSymbol &&
(mixinRefSymbol._kind === 'import' || mixinRefSymbol._kind === 'class')
) {
const refedMixin = {
mixin,
ref: mixinRefSymbol,
};
mixins.push(refedMixin);
this.meta.mixins.push(refedMixin);
} else {
this.diagnostics.warn(decl, processorWarnings.UNKNOWN_MIXIN(mixin.type), {
word: mixin.type,
});
}
});
if (rule.mixins) {
this.diagnostics.warn(decl, processorWarnings.OVERRIDE_MIXIN());
}
rule.mixins = mixins;
} else if (decl.prop === valueMapping.global) {
if (rule.isSimpleSelector && rule.selectorType !== 'element') {
this.setClassGlobalMapping(decl, rule);
} else {
// TODO: diagnostics - scoped on none class
}
}
}
protected setClassGlobalMapping(decl: postcss.Declaration, rule: postcss.Rule) {
const name = rule.selector.replace('.', '');
const typedRule = this.meta.classes[name];
if (typedRule) {
typedRule[valueMapping.global] = parseGlobal(decl, this.diagnostics);
}
}
protected extendTypedRule(
node: postcss.Node,
selector: string,
key: keyof StylableDirectives,
value: any
) {
const name = selector.replace('.', '');
const typedRule = this.meta.mappedSymbols[name] as ClassSymbol | ElementSymbol;
if (typedRule && typedRule[key]) {
this.diagnostics.warn(node, processorWarnings.OVERRIDE_TYPED_RULE(key, name), {
word: name,
});
}
if (typedRule) {
typedRule[key] = value;
}
}
protected handleImport(rule: postcss.Rule) {
let fromExists = false;
const importObj: Imported = {
defaultExport: '',
from: '',
fromRelative: '',
named: {},
rule,
context: path.dirname(this.meta.source),
};
rule.walkDecls((decl) => {
switch (decl.prop) {
case valueMapping.from: {
const importPath = stripQuotation(decl.value);
if (!importPath.trim()) {
this.diagnostics.error(decl, processorWarnings.EMPTY_IMPORT_FROM());
}
if (fromExists) {
this.diagnostics.warn(rule, processorWarnings.MULTIPLE_FROM_IN_IMPORT());
}
if (!path.isAbsolute(importPath) && !importPath.startsWith('.')) {
// 3rd party request
importObj.fromRelative = importPath;
importObj.from = importPath;
} else {
importObj.fromRelative = importPath;
const dirPath = path.dirname(this.meta.source);
importObj.from =
path.posix && path.posix.isAbsolute(dirPath) // browser has no posix methods
? path.posix.resolve(dirPath, importPath)
: path.resolve(dirPath, importPath);
}
fromExists = true;
break;
}
case valueMapping.default:
importObj.defaultExport = decl.value;
if (!isCompRoot(importObj.defaultExport) && importObj.from.match(/\.css$/)) {
this.diagnostics.warn(
decl,
processorWarnings.DEFAULT_IMPORT_IS_LOWER_CASE(),
{ word: importObj.defaultExport }
);
}
break;
case valueMapping.named:
importObj.named = parseNamed(decl.value);
break;
default:
this.diagnostics.warn(
decl,
processorWarnings.ILLEGAL_PROP_IN_IMPORT(decl.prop),
{ word: decl.prop }
);
break;
}
});
if (!importObj.from) {
this.diagnostics.error(rule, processorWarnings.FROM_PROP_MISSING_IN_IMPORT());
}
rule.remove();
return importObj;
}
}
export function validateScopingSelector(
atRule: postcss.AtRule,
{ selector: scopingSelector, isSimpleSelector }: SRule,
diagnostics: Diagnostics
) {
if (!scopingSelector) {
diagnostics.warn(atRule, processorWarnings.MISSING_SCOPING_PARAM());
} else if (!isSimpleSelector) {
diagnostics.warn(
atRule,
processorWarnings.SCOPE_PARAM_NOT_SIMPLE_SELECTOR(scopingSelector),
{ word: scopingSelector }
);
}
}
export function createEmptyMeta(root: postcss.Root, diagnostics: Diagnostics): StylableMeta {
deprecated(
'createEmptyMeta is deprecated and will be removed in the next version. Use "new StylableMeta()"'
);
return new StylableMeta(root, diagnostics);
}
export function processNamespace(namespace: string, source: string) {
return namespace + hash.v3(source); // .toString(36);
}
export function process(
root: postcss.Root,
diagnostics = new Diagnostics(),
resolveNamespace?: typeof processNamespace
) {
return new StylableProcessor(diagnostics, resolveNamespace).process(root);
}
// TODO: maybe put under stylable namespace object in v2
export interface SRule extends postcss.Rule {
selectorAst: SelectorAstNode;
isSimpleSelector: boolean;
selectorType: 'class' | 'element' | 'complex';
mixins?: RefedMixin[];
}
// TODO: maybe put under stylable namespace object in v2
export interface DeclStylableProps {
sourceValue: string;
}
export interface SDecl extends postcss.Declaration {
stylable: DeclStylableProps;
}