@stylable/core
Version:
CSS for Components
606 lines (573 loc) • 21.4 kB
text/typescript
import { createFeature, FeatureTransformContext } from './feature';
import * as STSymbol from './st-symbol';
import type { ImportSymbol } from './st-import';
import * as STCustomSelector from './st-custom-selector';
import * as STVar from './st-var';
import type { ElementSymbol } from './css-type';
import type { ClassSymbol } from './css-class';
import { createSubsetAst, isStMixinMarker } from '../helpers/rule';
import { scopeNestedSelector } from '../helpers/selector';
import { mixinHelperDiagnostics, parseStMixin, parseStPartialMixin } from '../helpers/mixin';
import { resolveArgumentsValue } from '../functions';
import { cssObjectToAst } from '../parser';
import * as postcss from 'postcss';
import postcssValueParser, { FunctionNode, WordNode } from 'postcss-value-parser';
import { fixRelativeUrls } from '../stylable-assets';
import { isValidDeclaration, mergeRules, utilDiagnostics } from '../stylable-utils';
import type { StylableMeta } from '../stylable-meta';
import type { CSSResolve, MetaResolvedSymbols } from '../stylable-resolver';
import type { StylableTransformer } from '../stylable-transformer';
import { dirname } from 'path';
import { createDiagnosticReporter, Diagnostics } from '../diagnostics';
import type { Stylable } from '../stylable';
import { parseCssSelector } from '@tokey/css-selector-parser';
export interface MixinValue {
type: string;
options: Array<{ value: string }> | Record<string, string>;
partial?: boolean;
valueNode?: FunctionNode | WordNode;
originDecl: postcss.Declaration;
}
export type ValidMixinSymbols = ImportSymbol | ClassSymbol | ElementSymbol;
export type AnalyzedMixin =
| {
valid: true;
data: MixinValue;
symbol: ValidMixinSymbols;
}
| {
valid: false;
data: MixinValue;
symbol: Exclude<STSymbol.StylableSymbol, ValidMixinSymbols> | undefined;
};
export type MixinReflection =
| {
name: string;
kind: 'css-fragment';
args: Record<string, string>[];
optionalArgs: Map<string, { name: string }>;
}
| { name: string; kind: 'js-func'; args: string[]; func: (...args: any[]) => any }
| { name: string; kind: 'invalid'; args: string };
export const MixinType = {
ALL: `-st-mixin`,
PARTIAL: `-st-partial-mixin`,
} as const;
export const diagnostics = {
VALUE_CANNOT_BE_STRING: mixinHelperDiagnostics.VALUE_CANNOT_BE_STRING,
INVALID_NAMED_PARAMS: mixinHelperDiagnostics.INVALID_NAMED_PARAMS,
INVALID_MERGE_OF: utilDiagnostics.INVALID_MERGE_OF,
INVALID_RECURSIVE_MIXIN: utilDiagnostics.INVALID_RECURSIVE_MIXIN,
PARTIAL_MIXIN_MISSING_ARGUMENTS: createDiagnosticReporter(
'10001',
'error',
(type: string) =>
`"${MixinType.PARTIAL}" can only be used with override arguments provided, missing overrides on "${type}"`
),
UNKNOWN_MIXIN: createDiagnosticReporter(
'10002',
'error',
(name: string) => `unknown mixin: "${name}"`
),
OVERRIDE_MIXIN: createDiagnosticReporter(
'10003',
'warning',
(mixinType: string) => `override ${mixinType} on same rule`
),
FAILED_TO_APPLY_MIXIN: createDiagnosticReporter(
'10004',
'error',
(error: string) => `could not apply mixin: ${error}`
),
JS_MIXIN_NOT_A_FUNC: createDiagnosticReporter(
'10005',
'error',
() => `js mixin must be a function`
),
UNSUPPORTED_MIXIN_SYMBOL: createDiagnosticReporter(
'10007',
'error',
(name: string, symbolType: STSymbol.StylableSymbol['_kind']) =>
`cannot mix unsupported symbol "${name}" of type "${STSymbol.readableTypeMap[symbolType]}"`
),
CIRCULAR_MIXIN: createDiagnosticReporter(
'10006',
'error',
(circularPaths: string[]) => `circular mixin found: ${circularPaths.join(' --> ')}`
),
UNKNOWN_ARG: createDiagnosticReporter(
'10009',
'warning',
(argName) => `unknown mixin argument "${argName}"`
),
};
// HOOKS
export const hooks = createFeature({
transformSelectorNode({ selectorContext, node }) {
const isMarker = isStMixinMarker(node);
if (isMarker) {
selectorContext.setNextSelectorScope(
selectorContext.inferredSelectorMixin,
node,
node.value
);
}
return isMarker;
},
transformLastPass({ context, ast, transformer, path }) {
ast.walkRules((rule) => appendMixins(context, transformer, rule, path));
},
});
// API
export class StylablePublicApi {
constructor(private stylable: Stylable) {}
public resolveExpr(
meta: StylableMeta,
expr: string,
{
diagnostics = new Diagnostics(),
resolveOptionalArgs = false,
}: { diagnostics?: Diagnostics; resolveOptionalArgs?: boolean } = {}
) {
const resolvedSymbols = this.stylable.resolver.resolveSymbols(meta, diagnostics);
const { mainNamespace } = resolvedSymbols;
const analyzedMixins = collectDeclMixins(
{ meta, diagnostics },
resolvedSymbols,
postcss.decl({ prop: '-st-mixin', value: expr }),
(mixinSymbolName) => (mainNamespace[mixinSymbolName] === 'js' ? 'args' : 'named')
);
const result: MixinReflection[] = [];
for (const { data } of analyzedMixins) {
const name = data.type;
const symbolKind = mainNamespace[name];
if (symbolKind === 'class' || symbolKind === 'element') {
const mixRef: MixinReflection = {
name,
kind: 'css-fragment',
args: [],
optionalArgs: new Map(),
};
for (const [argName, argValue] of Object.entries(data.options)) {
mixRef.args.push({ [argName]: argValue });
}
if (resolveOptionalArgs) {
const varMap = new Map<string, { name: string }>();
const resolveChain = resolvedSymbols[symbolKind][name];
getCSSMixinRoots(meta, resolveChain, ({ mixinRoot }) => {
const names = new Set<string>();
collectOptionalArgs(
{ meta, resolver: this.stylable.resolver },
mixinRoot,
names
);
names.forEach((name) => varMap.set(name, { name }));
});
mixRef.optionalArgs = varMap;
}
result.push(mixRef);
} else if (
symbolKind === 'js' &&
typeof resolvedSymbols.js[name].symbol === 'function'
) {
const mixRef: MixinReflection = {
name,
kind: 'js-func',
args: [],
func: resolvedSymbols.js[name].symbol as (...args: any[]) => any,
};
for (const arg of Object.values(data.options)) {
mixRef.args.push(arg.value);
}
result.push(mixRef);
} else {
result.push({
name,
kind: 'invalid',
args:
data.valueNode?.type === 'function'
? postcssValueParser.stringify(data.valueNode.nodes)
: '',
});
}
}
return result;
}
public scopeNestedSelector(scopeSelector: string, nestSelector: string): string {
return scopeNestedSelector(parseCssSelector(scopeSelector), parseCssSelector(nestSelector))
.selector;
}
}
function appendMixins(
context: FeatureTransformContext,
transformer: StylableTransformer,
rule: postcss.Rule,
path: string[] = []
) {
const [decls, mixins] = collectRuleMixins(context, rule);
if (!mixins || mixins.length === 0) {
return;
}
for (const mixin of mixins) {
if (mixin.valid) {
appendMixin(context, { transformer, mixDef: mixin, rule, path });
}
}
for (const mixinDecl of decls) {
mixinDecl.remove();
}
}
function collectRuleMixins(
context: FeatureTransformContext,
rule: postcss.Rule
): [decls: postcss.Declaration[], mixins: AnalyzedMixin[]] {
let mixins: AnalyzedMixin[] = [];
const resolvedSymbols = context.getResolvedSymbols(context.meta);
const { mainNamespace } = resolvedSymbols;
const decls: postcss.Declaration[] = [];
for (const node of rule.nodes) {
if (
node.type === 'decl' &&
(node.prop === `-st-mixin` || node.prop === `-st-partial-mixin`)
) {
decls.push(node);
mixins = collectDeclMixins(
context,
resolvedSymbols,
node,
(mixinSymbolName) => {
return mainNamespace[mixinSymbolName] === 'js' ? 'args' : 'named';
},
mixins
);
}
}
return [decls, mixins];
}
function collectDeclMixins(
context: Pick<FeatureTransformContext, 'meta' | 'diagnostics'>,
resolvedSymbols: MetaResolvedSymbols,
decl: postcss.Declaration,
paramSignature: (mixinSymbolName: string) => 'named' | 'args',
previousMixins?: AnalyzedMixin[]
): AnalyzedMixin[] {
const { meta } = context;
let mixins: AnalyzedMixin[] = [];
const parser =
decl.prop === MixinType.ALL
? parseStMixin
: decl.prop === MixinType.PARTIAL
? parseStPartialMixin
: null;
if (!parser) {
return previousMixins || mixins;
}
parser(decl, paramSignature, context.diagnostics, /*emitStrategyDiagnostics*/ true).forEach(
(mixin) => {
const mixinRefSymbol = STSymbol.get(meta, mixin.type);
const symbolName = mixin.type;
const resolvedType = resolvedSymbols.mainNamespace[symbolName];
if (
resolvedType &&
((resolvedType === 'js' &&
typeof resolvedSymbols.js[symbolName].symbol === 'function') ||
resolvedType === 'class' ||
resolvedType === 'element')
) {
mixins.push({
valid: true,
data: mixin,
symbol: mixinRefSymbol as ValidMixinSymbols,
});
if (mixin.partial && Object.keys(mixin.options).length === 0) {
context.diagnostics.report(
diagnostics.PARTIAL_MIXIN_MISSING_ARGUMENTS(mixin.type),
{
node: decl,
word: mixin.type,
}
);
}
} else {
mixins.push({
valid: false,
data: mixin,
symbol: mixinRefSymbol as
| Exclude<STSymbol.StylableSymbol, ValidMixinSymbols>
| undefined,
});
if (resolvedType === 'js') {
context.diagnostics.report(diagnostics.JS_MIXIN_NOT_A_FUNC(), {
node: decl,
word: mixin.type,
});
} else if (resolvedType) {
context.diagnostics.report(
diagnostics.UNSUPPORTED_MIXIN_SYMBOL(mixin.type, resolvedType),
{
node: decl,
word: mixin.type,
}
);
} else {
context.diagnostics.report(diagnostics.UNKNOWN_MIXIN(mixin.type), {
node: decl,
word: mixin.type,
});
}
}
}
);
if (previousMixins) {
const partials = previousMixins.filter((r) => r.data.partial);
const nonPartials = previousMixins.filter((r) => !r.data.partial);
const isInPartial = decl.prop === MixinType.PARTIAL;
if (
(partials.length && decl.prop === MixinType.PARTIAL) ||
(nonPartials.length && decl.prop === MixinType.ALL)
) {
context.diagnostics.report(diagnostics.OVERRIDE_MIXIN(decl.prop), { node: decl });
}
if (partials.length && nonPartials.length) {
mixins = isInPartial ? nonPartials.concat(mixins) : partials.concat(mixins);
} else if (partials.length) {
mixins = isInPartial ? mixins : partials.concat(mixins);
} else if (nonPartials.length) {
mixins = isInPartial ? nonPartials.concat(mixins) : mixins;
}
}
return mixins;
}
interface ApplyMixinContext {
transformer: StylableTransformer;
mixDef: AnalyzedMixin & { valid: true };
rule: postcss.Rule;
path: string[];
}
function appendMixin(context: FeatureTransformContext, config: ApplyMixinContext) {
if (checkRecursive(context, config)) {
return;
}
const resolvedSymbols = context.getResolvedSymbols(context.meta);
const symbolName = config.mixDef.data.type;
const resolvedType = resolvedSymbols.mainNamespace[symbolName];
if (resolvedType === `class` || resolvedType === `element`) {
const resolveChain = resolvedSymbols[resolvedType][symbolName];
handleCSSMixin(context, config, resolveChain);
return;
} else if (resolvedType === `js`) {
const jsValue = resolvedSymbols.js[symbolName].symbol;
if (typeof jsValue === 'function') {
try {
handleJSMixin(context, config, jsValue as (...args: any[]) => any);
} catch (e) {
context.diagnostics.report(diagnostics.FAILED_TO_APPLY_MIXIN(String(e)), {
node: config.rule,
word: config.mixDef.data.type,
});
return;
}
}
return;
}
}
function checkRecursive(
{ meta, diagnostics: report }: FeatureTransformContext,
{ mixDef, path, rule }: ApplyMixinContext
) {
const symbolName =
mixDef.symbol.name === meta.root
? mixDef.symbol._kind === 'class'
? meta.root
: 'default'
: mixDef.data.type;
const isRecursive = path.includes(symbolName + ' from ' + meta.source);
if (isRecursive) {
// Todo: add test verifying word
report.report(diagnostics.CIRCULAR_MIXIN(path), {
node: rule,
word: symbolName,
});
return true;
}
return false;
}
function handleJSMixin(
context: FeatureTransformContext,
config: ApplyMixinContext,
mixinFunction: (...args: any[]) => any
) {
const stVarOverride = context.evaluator.stVarOverride || {};
const meta = context.meta;
const mixDef = config.mixDef;
const res = mixinFunction((mixDef.data.options as any[]).map((v) => v.value));
const mixinRoot = cssObjectToAst(res);
mixinRoot.walkDecls((decl) => {
if (!isValidDeclaration(decl)) {
decl.value = String(decl);
}
});
config.transformer.transformAst(mixinRoot, meta, undefined, stVarOverride, [], true);
const mixinPath = (mixDef.symbol as ImportSymbol).import.request;
fixRelativeUrls(
mixinRoot,
context.resolver.resolvePath(dirname(meta.source), mixinPath),
meta.source
);
mergeRules(mixinRoot, config.rule, mixDef.data.originDecl, context.diagnostics, true);
}
function handleCSSMixin(
context: FeatureTransformContext,
config: ApplyMixinContext,
resolveChain: CSSResolve<ClassSymbol | ElementSymbol>[]
) {
const mixDef = config.mixDef;
const isPartial = mixDef.data.partial;
const namedArgs = mixDef.data.options as Record<string, string>;
const overrideKeys = Object.keys(namedArgs);
if (isPartial && overrideKeys.length === 0) {
return;
}
const optionalArgs = new Set<string>();
const roots = getCSSMixinRoots(
context.meta,
resolveChain,
({ mixinRoot, resolved, isRootMixin }) => {
const stVarOverride = context.evaluator.stVarOverride || {};
const mixDef = config.mixDef;
const namedArgs = mixDef.data.options as Record<string, string>;
if (mixDef.data.partial) {
filterPartialMixinDecl(context.meta, mixinRoot, Object.keys(namedArgs));
}
// resolve override args
const resolvedArgs = resolveArgumentsValue(
namedArgs,
config.transformer,
context.meta,
context.diagnostics,
mixDef.data.originDecl,
stVarOverride,
config.path
);
collectOptionalArgs(
{ meta: resolved.meta, resolver: context.resolver },
mixinRoot,
optionalArgs
);
// transform mixin
const mixinMeta: StylableMeta = resolved.meta;
const symbolName =
isRootMixin && resolved.meta !== context.meta ? 'default' : mixDef.data.type;
config.transformer.transformAst(
mixinRoot,
mixinMeta,
undefined,
resolvedArgs,
config.path.concat(symbolName + ' from ' + context.meta.source),
true,
config.transformer.createInferredSelector(mixinMeta, {
name: resolved.symbol.name,
type: resolved.symbol._kind,
})
);
fixRelativeUrls(mixinRoot, resolved.meta.source, context.meta.source);
}
);
for (const overrideArg of overrideKeys) {
if (!optionalArgs.has(overrideArg)) {
context.diagnostics.report(diagnostics.UNKNOWN_ARG(overrideArg), {
node: mixDef.data.originDecl,
word: overrideArg,
});
}
}
if (roots.length === 1) {
mergeRules(
roots[0],
config.rule,
mixDef.data.originDecl,
config.transformer.diagnostics,
false
);
} else if (roots.length > 1) {
const mixinRoot = postcss.root();
roots.forEach((root) => mixinRoot.prepend(...root.nodes));
mergeRules(
mixinRoot,
config.rule,
mixDef.data.originDecl,
config.transformer.diagnostics,
false
);
}
}
function collectOptionalArgs(
context: Pick<FeatureTransformContext, 'meta' | 'resolver'>,
mixinRoot: postcss.Root,
optionalArgs: Set<string> = new Set()
) {
mixinRoot.walk((node) => {
const value = node.type === 'decl' ? node.value : node.type === 'atrule' ? node.params : '';
const varNames = STVar.parseVarsFromExpr(value);
for (const name of varNames) {
for (const refName of STVar.resolveReferencedVarNames(context, name)) {
optionalArgs.add(refName);
}
}
});
}
function getCSSMixinRoots(
contextMeta: StylableMeta,
resolveChain: CSSResolve<ClassSymbol | ElementSymbol>[],
processMixinRoot: (data: {
mixinRoot: postcss.Root;
resolved: CSSResolve<ClassSymbol | ElementSymbol>;
isRootMixin: boolean;
}) => void
) {
const roots = [];
for (const resolved of resolveChain) {
const isRootMixin = resolved.symbol.name === resolved.meta.root;
const mixinRoot = createSubsetAst<postcss.Root>(
resolved.meta.sourceAst,
(resolved.symbol._kind === 'class' ? '.' : '') + resolved.symbol.name,
undefined,
isRootMixin,
(name) => STCustomSelector.getCustomSelector(contextMeta, name)
);
processMixinRoot({ mixinRoot, resolved, isRootMixin });
roots.push(mixinRoot);
if (resolved.symbol[`-st-extends`]) {
break;
}
}
return roots;
}
/** we assume that mixinRoot is freshly created nodes from the ast */
function filterPartialMixinDecl(
meta: StylableMeta,
mixinRoot: postcss.Root,
overrideKeys: string[]
) {
let regexp: RegExp;
const overrideSet = new Set(overrideKeys);
let size;
do {
size = overrideSet.size;
regexp = new RegExp(`value\\((\\s*${Array.from(overrideSet).join('\\s*)|(\\s*')}\\s*)\\)`);
for (const { text, name } of Object.values(meta.getAllStVars())) {
if (!overrideSet.has(name) && text.match(regexp)) {
overrideSet.add(name);
}
}
} while (overrideSet.size !== size);
mixinRoot.walkDecls((decl) => {
if (!decl.value.match(regexp)) {
const parent = decl.parent;
decl.remove();
if (parent?.nodes?.length === 0) {
parent.remove();
}
}
});
}