@stylable/core
Version:
CSS for Components
530 lines (492 loc) • 18.6 kB
text/typescript
import { createFeature, FeatureContext, FeatureTransformContext } from './feature';
import { unbox, Box, deprecatedStFunctions, boxString } from '../custom-values';
import { generalDiagnostics } from './diagnostics';
import * as STSymbol from './st-symbol';
import type { StylableMeta } from '../stylable-meta';
import { createSymbolResolverWithCache, CSSResolve } from '../stylable-resolver';
import { EvalValueData, EvalValueResult, StylableEvaluator } from '../functions';
import { isChildOfAtRule } from '../helpers/rule';
import { walkSelector } from '../helpers/selector';
import { stringifyFunction, getStringValue, strategies } from '../helpers/value';
import { stripQuotation } from '../helpers/string';
import type { ImmutablePseudoClass, PseudoClass } from '@tokey/css-selector-parser';
import type * as postcss from 'postcss';
import { processDeclarationFunctions } from '../process-declaration-functions';
import { createDiagnosticReporter, Diagnostics } from '../diagnostics';
import type { ParsedValue } from '../types';
import type { Stylable } from '../stylable';
import type { RuntimeStVar } from '../stylable-transformer';
import postcssValueParser from 'postcss-value-parser';
export interface VarSymbol {
_kind: 'var';
name: string;
value: string;
text: string;
valueType: string | null;
node: postcss.Node;
}
export type CustomValueInput = Box<
string,
CustomValueInput | Record<string, CustomValueInput | string> | Array<CustomValueInput | string>
>;
export interface ComputedStVar {
value: RuntimeStVar;
diagnostics: Diagnostics;
input: CustomValueInput;
source: {
meta: StylableMeta;
start: postcss.Position;
end: postcss.Position;
};
}
export interface FlatComputedStVar {
value: string;
path: string[];
source: ComputedStVar['source'];
}
export const diagnostics = {
FORBIDDEN_DEF_IN_COMPLEX_SELECTOR: generalDiagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR,
NO_VARS_DEF_IN_ST_SCOPE: createDiagnosticReporter(
'07002',
'error',
() => `cannot define ":vars" inside of "@st-scope"`
),
DEPRECATED_ST_FUNCTION_NAME: createDiagnosticReporter(
'07003',
'info',
(name: string, alternativeName: string) =>
`"${name}" is deprecated, use "${alternativeName}"`
),
CYCLIC_VALUE: createDiagnosticReporter(
'07004',
'error',
(cyclicChain: string[]) =>
`Cyclic value definition detected: "${cyclicChain
.map((s, i) => (i === cyclicChain.length - 1 ? '↻ ' : i === 0 ? '→ ' : '↪ ') + s)
.join('\n')}"`
),
MISSING_VAR_IN_VALUE: createDiagnosticReporter(
'07005',
'error',
() => `invalid value() with no var identifier`
),
COULD_NOT_RESOLVE_VALUE: createDiagnosticReporter(
'07006',
'error',
(args?: string) =>
`cannot resolve value function${args ? ` using the arguments provided: "${args}"` : ''}`
),
MULTI_ARGS_IN_VALUE: createDiagnosticReporter(
'07007',
'error',
(args: string) => `value function accepts only a single argument: "value(${args})"`
),
CANNOT_USE_AS_VALUE: createDiagnosticReporter(
'07008',
'error',
(type: string, varName: string) => `${type} "${varName}" cannot be used as a variable`
),
CANNOT_USE_JS_AS_VALUE: createDiagnosticReporter(
'07009',
'error',
(type: string, varName: string) =>
`JavaScript ${type} import "${varName}" cannot be used as a variable`
),
UNKNOWN_VAR: createDiagnosticReporter(
'07010',
'error',
(name: string) => `unknown var "${name}"`
),
};
// HOOKS
export const hooks = createFeature<{
SELECTOR: PseudoClass;
IMMUTABLE_SELECTOR: ImmutablePseudoClass;
RESOLVED: Record<string, EvalValueResult>;
}>({
analyzeSelectorNode({ context, node, rule }) {
if (node.type !== `pseudo_class` || node.value !== `vars`) {
return;
}
// make sure `:vars` is the only selector
if (rule.selector === `:vars`) {
if (isChildOfAtRule(rule, `st-scope`)) {
context.diagnostics.report(diagnostics.NO_VARS_DEF_IN_ST_SCOPE(), { node: rule });
} else {
collectVarSymbols(context, rule);
}
// stop further walk into `:vars {}`
return walkSelector.stopAll;
} else {
context.diagnostics.report(diagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(`:vars`), {
node: rule,
});
}
return;
},
prepareAST({ node, toRemove }) {
if (node.type === 'rule' && node.selector === ':vars') {
toRemove.push(node);
}
},
transformResolve({ context }) {
// Resolve local vars
const resolved: Record<string, any> = {};
const symbols = STSymbol.getAllByType(context.meta, `var`);
// Temporarily don't report issues here // ToDo: move reporting here (from value() transformation)
const noDaigContext = {
...context,
diagnostics: new Diagnostics(),
};
for (const name of Object.keys(symbols)) {
const symbol = symbols[name];
const evaluated = context.evaluator.evaluateValue(noDaigContext, {
// ToDo: change to `value(${name})` in order to fix overrides in exports
value: stripQuotation(symbol.text),
meta: context.meta,
node: symbol.node,
});
resolved[name] = evaluated;
}
return resolved;
},
transformValue({ context, node, data }) {
evaluateValueCall(context, node, data);
},
transformJSExports({ exports, resolved }) {
for (const [name, { topLevelType, outputValue }] of Object.entries(resolved)) {
exports.stVars[name] = topLevelType ? unbox(topLevelType) : outputValue;
}
},
});
// API
export function get(meta: StylableMeta, name: string): VarSymbol | undefined {
return STSymbol.get(meta, name, `var`);
}
// Stylable StVar Public APIs
const UNKNOWN_LOCATION = Object.freeze({
offset: -1,
line: -1,
column: -1,
} as const);
export class StylablePublicApi {
constructor(private stylable: Stylable) {}
public getComputed(meta: StylableMeta) {
const topLevelDiagnostics = new Diagnostics();
const getResolvedSymbols = createSymbolResolverWithCache(
this.stylable.resolver,
topLevelDiagnostics
);
const evaluator = new StylableEvaluator({ getResolvedSymbols });
const { var: stVars } = getResolvedSymbols(meta);
const computed: Record<string, ComputedStVar> = {};
for (const [localName, resolvedVar] of Object.entries(stVars)) {
const diagnostics = new Diagnostics();
const { outputValue, topLevelType, runtimeValue } = evaluator.evaluateValue(
{
resolver: this.stylable.resolver,
evaluator,
meta,
diagnostics,
},
{
meta: resolvedVar.meta,
value: stripQuotation(resolvedVar.symbol.text),
node: resolvedVar.symbol.node,
}
);
const computedStVar: ComputedStVar = {
value: runtimeValue ?? outputValue,
input: topLevelType ?? unbox(outputValue, false),
diagnostics,
source: {
meta: resolvedVar.meta,
start: resolvedVar.symbol.node.source?.start || UNKNOWN_LOCATION,
end: resolvedVar.symbol.node.source?.end || UNKNOWN_LOCATION,
},
};
computed[localName] = computedStVar;
}
return computed;
}
public flatten(meta: StylableMeta) {
const computed = this.getComputed(meta);
const flatStVars: FlatComputedStVar[] = [];
for (const [symbol, stVar] of Object.entries(computed)) {
flatStVars.push(...this.flatSingle(stVar.input, [symbol], stVar.source));
}
return flatStVars;
}
private flatSingle(input: CustomValueInput, path: string[], source: ComputedStVar['source']) {
const currentVars: FlatComputedStVar[] = [];
if (input.flatValue) {
currentVars.push({
value: input.flatValue,
path,
source,
});
}
if (typeof input.value === `object` && input.value !== null) {
for (const [key, innerInput] of Object.entries(input.value)) {
currentVars.push(
...this.flatSingle(
typeof innerInput === 'string' ? boxString(innerInput) : innerInput,
[...path, key],
source
)
);
}
}
return currentVars;
}
}
export function parseVarsFromExpr(expr: string) {
const nameSet = new Set<string>();
postcssValueParser(expr).walk((node) => {
if (node.type === 'function' && node.value === 'value') {
for (const argNode of node.nodes) {
switch (argNode.type) {
case 'word':
nameSet.add(argNode.value);
return;
case 'div':
if (argNode.value === ',') {
return;
}
}
}
}
});
return nameSet;
}
function collectVarSymbols(context: FeatureContext, rule: postcss.Rule) {
rule.walkDecls((decl) => {
warnOnDeprecatedCustomValues(context, decl);
// check type annotation
let type = null;
const prev = decl.prev() as postcss.Comment;
if (prev && prev.type === 'comment') {
const typeMatch = prev.text.match(/^@type (.+)$/);
if (typeMatch) {
type = typeMatch[1];
}
}
// add symbol
const name = decl.prop;
STSymbol.addSymbol({
context,
symbol: {
_kind: 'var',
name,
value: '',
text: decl.value,
node: decl,
valueType: type,
},
node: decl,
});
});
}
function warnOnDeprecatedCustomValues(context: FeatureContext, decl: postcss.Declaration) {
processDeclarationFunctions(
decl,
(node) => {
if (node.type === 'nested-item' && deprecatedStFunctions[node.name]) {
const { alternativeName } = deprecatedStFunctions[node.name];
context.diagnostics.report(
diagnostics.DEPRECATED_ST_FUNCTION_NAME(node.name, alternativeName),
{
node: decl,
word: node.name,
}
);
}
},
false
);
}
function evaluateValueCall(
context: FeatureTransformContext,
parsedNode: ParsedValue,
data: EvalValueData
): void {
const { stVarOverride, value, node } = data;
const passedThrough = context.passedThrough || [];
const parsedArgs = strategies.args(parsedNode).map((x) => x.value);
const varName = parsedArgs[0];
const restArgs = parsedArgs.slice(1);
// check var not empty
if (!varName) {
if (node) {
context.diagnostics.report(diagnostics.MISSING_VAR_IN_VALUE(), {
node,
word: getStringValue(parsedNode),
});
}
} else if (parsedArgs.length >= 1) {
// override with value
if (stVarOverride?.[varName]) {
parsedNode.resolvedValue = stVarOverride?.[varName];
return;
}
// check cyclic
const refUniqID = createUniqID(data.meta.source, varName);
if (passedThrough.includes(refUniqID)) {
// TODO: move diagnostic to original value usage instead of the end of the cyclic chain
handleCyclicValues(context, passedThrough, refUniqID, data.node, value, parsedNode);
return;
}
// resolve
const resolvedSymbols = context.getResolvedSymbols(data.meta);
const resolvedVar = resolvedSymbols.var[varName];
const resolvedVarSymbol = resolvedVar?.symbol;
const possibleNonSTVarSymbol = STSymbol.get(context.meta, varName);
if (resolvedVarSymbol) {
const { outputValue, topLevelType, typeError } = context.evaluator.evaluateValue(
{ ...context, passedThrough: passedThrough.concat(refUniqID) },
{
...data,
value: stripQuotation(resolvedVarSymbol.text),
args: restArgs,
node: resolvedVarSymbol.node,
meta: resolvedVar.meta,
rootArgument: varName,
initialNode: node,
}
);
// report errors
if (node) {
const argsAsString = parsedArgs.join(', ');
if (!typeError && !topLevelType && parsedArgs.length > 1) {
context.diagnostics.report(diagnostics.MULTI_ARGS_IN_VALUE(argsAsString), {
node,
});
}
}
parsedNode.resolvedValue = context.evaluator.valueHook
? context.evaluator.valueHook(outputValue, varName, true, passedThrough)
: outputValue;
} else if (possibleNonSTVarSymbol) {
const type = resolvedSymbols.mainNamespace[varName];
if (type === `js`) {
const deepResolve = resolvedSymbols.js[varName];
const jsValue = deepResolve.symbol;
if (typeof jsValue === 'string') {
parsedNode.resolvedValue = context.evaluator.valueHook
? context.evaluator.valueHook(jsValue, varName, false, passedThrough)
: jsValue;
} else if (node) {
// unsupported Javascript value
// ToDo: provide actual exported id (default/named as x)
context.diagnostics.report(
diagnostics.CANNOT_USE_JS_AS_VALUE(typeof jsValue, varName),
{
node,
word: varName,
}
);
}
} else if (type) {
// report mismatch type
const deepResolve = resolvedSymbols[type][varName];
let finalResolve: CSSResolve = {
_kind: `css`,
meta: data.meta,
symbol: possibleNonSTVarSymbol,
};
if (deepResolve instanceof Array) {
// take the deep resolved in order to
// print the actual mismatched type
finalResolve = deepResolve[deepResolve.length - 1];
} else if (deepResolve._kind === `css`) {
finalResolve = deepResolve;
}
reportUnsupportedSymbolInValue(context, varName, finalResolve, node);
} else if (node) {
// report unknown var
context.diagnostics.report(diagnostics.UNKNOWN_VAR(varName), {
node,
word: varName,
});
}
} else if (node) {
context.diagnostics.report(diagnostics.UNKNOWN_VAR(varName), {
node,
word: varName,
});
}
}
}
export function resolveReferencedVarNames(
context: Pick<FeatureTransformContext, 'meta' | 'resolver'>,
initialName: string
) {
const refNames = new Set<string>();
const varsToCheck: { meta: StylableMeta; name: string }[] = [
{ meta: context.meta, name: initialName },
];
const checked = new Set<string>();
while (varsToCheck.length) {
const { meta, name } = varsToCheck.shift()!;
const contextualId = meta.source + '/' + name;
if (!checked.has(contextualId)) {
checked.add(contextualId);
refNames.add(name);
const symbol = STSymbol.get(meta, name);
switch (symbol?._kind) {
case 'var':
parseVarsFromExpr(symbol.text).forEach((refName) =>
varsToCheck.push({
meta,
name: refName,
})
);
break;
case 'import': {
const resolved = context.resolver.deepResolve(symbol);
if (resolved?._kind === 'css' && resolved.symbol?._kind === 'var') {
varsToCheck.push({ meta: resolved.meta, name: resolved.symbol.name });
}
break;
}
}
}
}
return refNames;
}
function reportUnsupportedSymbolInValue(
context: FeatureTransformContext,
name: string,
resolve: CSSResolve,
node: postcss.Node | undefined
) {
const symbol = resolve.symbol;
const errorKind = symbol._kind === 'class' && symbol[`
-st-root`] ? 'stylesheet' : symbol._kind;
if (node) {
context.diagnostics.report(diagnostics.CANNOT_USE_AS_VALUE(errorKind, name), {
node,
word: name,
});
}
}
function handleCyclicValues(
context: FeatureTransformContext,
passedThrough: string[],
refUniqID: string,
node: postcss.Node | undefined,
value: string,
parsedNode: ParsedValue
) {
if (node) {
const cyclicChain = passedThrough.map((variable) => variable || '');
cyclicChain.push(refUniqID);
context.diagnostics.report(diagnostics.CYCLIC_VALUE(cyclicChain), {
node,
word: refUniqID, // ToDo: check word is path+var and not var name
});
}
return stringifyFunction(value, parsedNode);
}
function createUniqID(source: string, varName: string) {
return `${source}: ${varName}`;
}