diff --git a/packages/core/src/custom-values.ts b/packages/core/src/custom-values.ts index 42f248125..6f67e4de9 100644 --- a/packages/core/src/custom-values.ts +++ b/packages/core/src/custom-values.ts @@ -3,25 +3,45 @@ import postcssValueParser from 'postcss-value-parser'; import { getFormatterArgs, getNamedArgs, getStringValue } from './helpers/value'; import type { ParsedValue } from './types'; +export class ValueError extends Error { + constructor(message: string, public fallbackValue: string) { + super(message); + } +} + export interface Box { type: Type; value: Value; + flatValue: string | undefined; } -export function box(type: Type, value: Value): Box { +export function box( + type: Type, + value: Value, + flatValue?: string +): Box { return { type, value, + flatValue, }; } -const { hasOwnProperty } = Object.prototype; - -export function unbox>(boxed: B | string): any { +export function unbox>( + boxed: B | string, + unboxPrimitives = true, + customValues?: CustomTypes, + node?: ParsedValue +): any { if (typeof boxed === 'string') { - return boxed; - } else if (typeof boxed === 'object' && boxed.type && hasOwnProperty.call(boxed, 'value')) { - return cloneDeepWith(boxed.value, unbox); + return unboxPrimitives ? boxed : box('string', boxed); + } else if (typeof boxed === 'object' && boxed !== null) { + const customValue = customValues?.[boxed.type]; + let value = boxed.value; + if (customValue?.flattenValue && node) { + value = customValue.getValue([], boxed, node, customValues!); + } + return cloneDeepWith(value, (v) => unbox(v, unboxPrimitives, customValues, node)); } } @@ -39,7 +59,8 @@ export interface CustomValueExtension { valueAst: ParsedValue, customTypes: { [typeID: string]: CustomValueExtension; - } + }, + boxPrimitive?: boolean ): Box; getValue( path: string[], @@ -51,8 +72,8 @@ export interface CustomValueExtension { function createStArrayCustomFunction() { return createCustomValue({ - processArgs: (node, customTypes) => { - return CustomValueStrategy.args(node, customTypes); + processArgs: (node, customTypes, boxPrimitive) => { + return CustomValueStrategy.args(node, customTypes, boxPrimitive); }, createValue: (args) => { return args; @@ -63,8 +84,8 @@ function createStArrayCustomFunction() { function createStMapCustomFunction() { return createCustomValue({ - processArgs: (node, customTypes) => { - return CustomValueStrategy.named(node, customTypes); + processArgs: (node, customTypes, boxPrimitive) => { + return CustomValueStrategy.named(node, customTypes, boxPrimitive); }, createValue: (args) => { return args; @@ -92,7 +113,7 @@ export const deprecatedStFunctions: Record }; export const CustomValueStrategy = { - args: (fnNode: ParsedValue, customTypes: CustomTypes) => { + args: (fnNode: ParsedValue, customTypes: CustomTypes, boxPrimitive?: boolean) => { const pathArgs = getFormatterArgs(fnNode); const outputArray = []; for (const arg of pathArgs) { @@ -100,13 +121,13 @@ export const CustomValueStrategy = { const ct = parsedArg.type === 'function' && parsedArg.value; const resolvedValue = typeof ct === 'string' && customTypes[ct] - ? customTypes[ct].evalVarAst(parsedArg, customTypes) - : arg; + ? customTypes[ct].evalVarAst(parsedArg, customTypes, boxPrimitive) + : unbox(arg, !boxPrimitive); outputArray.push(resolvedValue); } return outputArray; }, - named: (fnNode: ParsedValue, customTypes: CustomTypes) => { + named: (fnNode: ParsedValue, customTypes: CustomTypes, boxPrimitive?: boolean) => { const outputMap: BoxedValueMap = {}; const s = getNamedArgs(fnNode); for (const [prop, space, ...valueNodes] of s) { @@ -124,13 +145,13 @@ export const CustomValueStrategy = { if (!resolvedValue) { const ct = customTypes[valueNode.value]; if (valueNode.type === 'function' && ct) { - resolvedValue = ct.evalVarAst(valueNode, customTypes); + resolvedValue = ct.evalVarAst(valueNode, customTypes, boxPrimitive); } else { - resolvedValue = getStringValue(valueNode); + resolvedValue = unbox(getStringValue(valueNode), !boxPrimitive); } } } else { - resolvedValue = getStringValue(valueNodes); + resolvedValue = unbox(getStringValue(valueNodes), !boxPrimitive); } if (resolvedValue) { @@ -152,7 +173,7 @@ type FlattenValue = (v: Box) => { }; interface ExtensionApi { - processArgs: (fnNode: ParsedValue, customTypes: CustomTypes) => Args; + processArgs: (fnNode: ParsedValue, customTypes: CustomTypes, boxPrimitive?: boolean) => Args; createValue: (args: Args) => Value; getValue: (v: Value, key: string) => string | Box; flattenValue?: FlattenValue; @@ -169,9 +190,21 @@ export function createCustomValue({ register(localTypeSymbol: string) { return { flattenValue, - evalVarAst(fnNode: ParsedValue, customTypes: CustomTypes) { - const args = processArgs(fnNode, customTypes); - return box(localTypeSymbol, createValue(args)); + evalVarAst(fnNode: ParsedValue, customTypes: CustomTypes, boxPrimitive?: boolean) { + const args = processArgs(fnNode, customTypes, boxPrimitive); + const value = createValue(args); + let flatValue: string | undefined; + + if (flattenValue) { + flatValue = getFlatValue( + flattenValue, + box(localTypeSymbol, value), + fnNode, + customTypes + ); + } + + return box(localTypeSymbol, value, flatValue); }, getValue( path: string[], @@ -181,13 +214,14 @@ export function createCustomValue({ ): string { if (path.length === 0) { if (flattenValue) { - const { delimiter, parts } = flattenValue(obj); - return parts - .map((v) => getBoxValue([], v, fallbackNode, customTypes)) - .join(delimiter); + return getFlatValue(flattenValue, obj, fallbackNode, customTypes); } else { - // TODO: add diagnostics - return getStringValue([fallbackNode]); + const stringifiedValue = getStringValue([fallbackNode]); + + throw new ValueError( + `/* Error trying to flat -> */${stringifiedValue}`, + stringifiedValue + ); } } const value = getValue(obj.value, path[0]); @@ -198,6 +232,16 @@ export function createCustomValue({ }; } +function getFlatValue( + flattenValue: FlattenValue, + obj: Box, + fallbackNode: ParsedValue, + customTypes: CustomTypes +) { + const { delimiter, parts } = flattenValue(obj); + return parts.map((v) => getBoxValue([], v, fallbackNode, customTypes)).join(delimiter); +} + export function getBoxValue( path: string[], value: string | Box, @@ -208,6 +252,8 @@ export function getBoxValue( return value; } else if (value && customTypes[value.type]) { return customTypes[value.type].getValue(path, value, node, customTypes); + } else if (value.type === 'string') { + return (value as Box<'string', string>).value; } else { throw new Error('Unknown Type ' + JSON.stringify(value)); // return JSON.stringify(value); diff --git a/packages/core/src/features/st-var.ts b/packages/core/src/features/st-var.ts index 8fd073c85..fb6e7c588 100644 --- a/packages/core/src/features/st-var.ts +++ b/packages/core/src/features/st-var.ts @@ -1,5 +1,5 @@ import { createFeature, FeatureContext, FeatureTransformContext } from './feature'; -import { deprecatedStFunctions } from '../custom-values'; +import { unbox, Box, deprecatedStFunctions } from '../custom-values'; import { generalDiagnostics } from './diagnostics'; import * as STSymbol from './st-symbol'; import type { StylableMeta } from '../stylable-meta'; @@ -14,7 +14,6 @@ import type { ImmutablePseudoClass, PseudoClass } from '@tokey/css-selector-pars import type * as postcss from 'postcss'; import { processDeclarationFunctions } from '../process-declaration-functions'; import { Diagnostics } from '../diagnostics'; -import { unbox } from '../custom-values'; import type { ParsedValue } from '../types'; import type { Stylable } from '../stylable'; import type { RuntimeStVar } from '../stylable-transformer'; @@ -28,10 +27,12 @@ export interface VarSymbol { node: postcss.Node; } +export type Input = Box | Array>; + export interface ComputedStVar { value: RuntimeStVar; diagnostics: Diagnostics; - input?: any; + input: Input; } export const diagnostics = { @@ -47,8 +48,8 @@ export const diagnostics = { .map((s, i) => (i === cyclicChain.length - 1 ? '↻ ' : i === 0 ? '→ ' : '↪ ') + s) .join('\n')}"`, MISSING_VAR_IN_VALUE: () => `invalid value() with no var identifier`, - COULD_NOT_RESOLVE_VALUE: (args: string) => - `cannot resolve value function using the arguments provided: "${args}"`, + COULD_NOT_RESOLVE_VALUE: (args?: string) => + `cannot resolve value function${args ? ` using the arguments provided: "${args}"` : ''}`, MULTI_ARGS_IN_VALUE: (args: string) => `value function accepts only a single argument: "value(${args})"`, CANNOT_USE_AS_VALUE: (type: string, varName: string) => @@ -133,13 +134,13 @@ export class StylablePublicApi { topLevelDiagnostics ); - const { var: stVars, customValues } = getResolvedSymbols(meta); + const { var: stVars } = getResolvedSymbols(meta); const computed: Record = {}; for (const [localName, resolvedVar] of Object.entries(stVars)) { const diagnostics = new Diagnostics(); - const { outputValue, topLevelType } = evaluator.evaluateValue( + const { outputValue, topLevelType, runtimeValue } = evaluator.evaluateValue( { getResolvedSymbols, resolver: this.stylable.resolver, @@ -154,20 +155,12 @@ export class StylablePublicApi { } ); - const customValue = customValues[topLevelType?.type]; const computedStVar: ComputedStVar = { - /** - * In case of custom value that could be flat, we will use the "outputValue" which is a flat value. - */ - value: - topLevelType && !customValue?.flattenValue ? unbox(topLevelType) : outputValue, + value: runtimeValue ?? outputValue, + input: topLevelType ?? unbox(outputValue, false), diagnostics, }; - if (customValue?.flattenValue) { - computedStVar.input = unbox(topLevelType); - } - computed[localName] = computedStVar; } @@ -286,17 +279,14 @@ function evaluateValueCall( args: restArgs, node: resolvedVarSymbol.node, meta: resolvedVar.meta, + rootArgument: varName, + evaluatorNode: node, } ); // report errors if (node) { const argsAsString = parsedArgs.join(', '); - if (typeError) { - context.diagnostics.warn( - node, - diagnostics.COULD_NOT_RESOLVE_VALUE(argsAsString) - ); - } else if (!topLevelType && parsedArgs.length > 1) { + if (!typeError && !topLevelType && parsedArgs.length > 1) { context.diagnostics.warn(node, diagnostics.MULTI_ARGS_IN_VALUE(argsAsString)); } } diff --git a/packages/core/src/functions.ts b/packages/core/src/functions.ts index 2866ab3f5..0d56f8a58 100644 --- a/packages/core/src/functions.ts +++ b/packages/core/src/functions.ts @@ -12,11 +12,12 @@ import { createSymbolResolverWithCache, MetaResolvedSymbols, } from './stylable-resolver'; -import type { replaceValueHook, StylableTransformer } from './stylable-transformer'; +import type { replaceValueHook, RuntimeStVar, StylableTransformer } from './stylable-transformer'; import { getFormatterArgs, getStringValue, stringifyFunction } from './helpers/value'; import type { ParsedValue } from './types'; import type { FeatureTransformContext } from './features/feature'; import { CSSCustomProperty, STVar } from './features'; +import { unbox, ValueError } from './custom-values'; export type ValueFormatter = (name: string) => string; export type ResolvedFormatter = Record; @@ -30,10 +31,13 @@ export interface EvalValueData { tsVarOverride?: Record | null; cssVarsMapping?: Record; args?: string[]; + rootArgument?: string; + evaluatorNode?: postcss.Node; } export interface EvalValueResult { topLevelType: any; + runtimeValue: RuntimeStVar; outputValue: string; typeError?: Error; } @@ -58,7 +62,9 @@ export class StylableEvaluator { context.diagnostics, data.passedThrough, data.cssVarsMapping, - data.args + data.args, + data.rootArgument, + data.evaluatorNode ); } } @@ -111,7 +117,9 @@ export function processDeclarationValue( diagnostics: Diagnostics = new Diagnostics(), passedThrough: string[] = [], cssVarsMapping: Record = {}, - args: string[] = [] + args: string[] = [], + rootArgument?: string, + evaluatorNode?: postcss.Node ): EvalValueResult { const evaluator = new StylableEvaluator({ tsVarOverride: variableOverride }); const resolvedSymbols = getResolvedSymbols(meta); @@ -137,6 +145,8 @@ export function processDeclarationValue( tsVarOverride: variableOverride, cssVarsMapping, args, + rootArgument, + evaluatorNode, }, node: parsedNode, }); @@ -204,6 +214,8 @@ export function processDeclarationValue( tsVarOverride: variableOverride, cssVarsMapping, args, + rootArgument, + evaluatorNode, }, node: parsedNode, }); @@ -220,23 +232,46 @@ export function processDeclarationValue( let outputValue = ''; let topLevelType = null; + let runtimeValue = null; let typeError: Error | undefined = undefined; for (const n of parsedValue.nodes) { if (n.type === 'function') { const matchingType = resolvedSymbols.customValues[n.value]; if (matchingType) { - topLevelType = matchingType.evalVarAst(n, resolvedSymbols.customValues); try { - outputValue += matchingType.getValue( - args, - topLevelType, - n, - resolvedSymbols.customValues - ); + topLevelType = matchingType.evalVarAst(n, resolvedSymbols.customValues, true); + runtimeValue = unbox(topLevelType, true, resolvedSymbols.customValues, n); + try { + outputValue += matchingType.getValue( + args, + topLevelType, + n, + resolvedSymbols.customValues + ); + } catch (error) { + if (error instanceof ValueError) { + outputValue += error.fallbackValue; + } else { + throw error; + } + } } catch (e) { typeError = e as Error; - // catch broken variable resolutions + + const invalidNode = evaluatorNode || node; + + if (invalidNode) { + diagnostics.warn( + invalidNode, + STVar.diagnostics.COULD_NOT_RESOLVE_VALUE( + [...(rootArgument ? [rootArgument] : []), ...args].join(', ') + ), + { word: value } + ); + } else { + // TODO: catch broken variable resolutions without a node + } } } else { outputValue += getStringValue([n]); @@ -245,7 +280,7 @@ export function processDeclarationValue( outputValue += getStringValue([n]); } } - return { outputValue, topLevelType, typeError }; + return { outputValue, topLevelType, typeError, runtimeValue }; } export function evalDeclarationValue( diff --git a/packages/core/src/stylable-transformer.ts b/packages/core/src/stylable-transformer.ts index c9a00874f..dbe0913fb 100644 --- a/packages/core/src/stylable-transformer.ts +++ b/packages/core/src/stylable-transformer.ts @@ -204,6 +204,7 @@ export class StylableTransformer { node: atRule, valueHook: this.replaceValueHook, passedThrough: path.slice(), + evaluatorNode: atRule, }).outputValue; } else if (name === 'property') { CSSCustomProperty.hooks.transformAtRuleNode({ diff --git a/packages/core/test/features/st-var.spec.ts b/packages/core/test/features/st-var.spec.ts index 99df56364..b1a33585c 100644 --- a/packages/core/test/features/st-var.spec.ts +++ b/packages/core/test/features/st-var.spec.ts @@ -10,6 +10,31 @@ import postcssValueParser from 'postcss-value-parser'; chai.use(chaiSubset); describe(`features/st-var`, () => { + const stBorderDefinitionMock = ` + const { createCustomValue, CustomValueStrategy } = require("@stylable/core"); + exports.stBorder = createCustomValue({ + processArgs: (node, customTypes) => { + return CustomValueStrategy.args(node, customTypes); + }, + createValue: ([size, style, color]) => { + return { + size, + style, + color, + }; + }, + getValue: (value, index) => { + return value[index]; + }, + flattenValue: ({ value: { size, style, color } }) => { + return { + delimiter: ' ', + parts: [size, style, color], + }; + }, + }) + `; + it(`should process :vars definitions`, () => { const { sheets } = testStylableCore(` /* @transform-remove */ @@ -1274,55 +1299,90 @@ describe(`features/st-var`, () => { expect(Object.keys(computedVars)).to.eql(['a', 'b', 'c']); expect(computedVars.a).to.containSubset({ value: 'red', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'red', + }, diagnostics: { reports: [] }, }); expect(computedVars.b).to.containSubset({ value: 'blue', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'blue', + }, diagnostics: { reports: [] }, }); expect(computedVars.c).to.containSubset({ value: ['red', 'gold'], - input: undefined, + input: { + type: 'st-array', + value: [ + { + flatValue: undefined, + type: 'string', + value: 'red', + }, + { + flatValue: undefined, + type: 'string', + value: 'gold', + }, + ], + }, diagnostics: { reports: [] }, }); }); + it('should get deep computed complex st-vars', () => { + const { stylable, sheets } = testStylableCore(` + :vars { + map: st-map(a st-map(b red)); + } + `); + + const { meta } = sheets['/entry.st.css']; + const computedVars = stylable.stVar.getComputed(meta); + + expect(Object.keys(computedVars)).to.eql(['map']); + expect(computedVars.map.diagnostics.reports.length).to.eql(0); + expect(computedVars.map.value).to.eql({ + a: { + b: 'red', + }, + }); + expect(computedVars.map.input).to.eql({ + type: 'st-map', + flatValue: undefined, + value: { + a: { + type: 'st-map', + flatValue: undefined, + value: { + b: { + flatValue: undefined, + type: 'string', + value: 'red', + }, + }, + }, + }, + }); + }); + it('should get computed custom value st-var', () => { const { stylable, sheets } = testStylableCore({ '/entry.st.css': ` - @st-import [stBorder] from './st-border.js'; + @st-import [stBorder as createBorder] from './st-border.js'; :vars { - border: stBorder(1px, solid, red); + border: createBorder(1px, solid, red); } `, // Stylable custom value - '/st-border.js': ` - const { createCustomValue, CustomValueStrategy } = require("@stylable/core"); - exports.stBorder = createCustomValue({ - processArgs: (node, customTypes) => { - return CustomValueStrategy.args(node, customTypes); - }, - createValue: ([size, style, color]) => { - return { - size, - style, - color, - }; - }, - getValue: (value, index) => { - return value[index]; - }, - flattenValue: ({ value: { size, style, color } }) => { - return { - delimiter: ' ', - parts: [size, style, color], - }; - }, - }) - `, + '/st-border.js': stBorderDefinitionMock, }); const { meta } = sheets['/entry.st.css']; @@ -1332,9 +1392,82 @@ describe(`features/st-var`, () => { expect(computedVars.border).to.containSubset({ value: '1px solid red', input: { - color: 'red', - size: '1px', - style: 'solid', + type: 'createBorder', + flatValue: '1px solid red', + value: { + color: 'red', + size: '1px', + style: 'solid', + }, + }, + diagnostics: { reports: [] }, + }); + }); + + it('should get deep computed custom value st-var', () => { + const { stylable, sheets } = testStylableCore({ + '/entry.st.css': ` + @st-import [stBorder] from './st-border.js'; + + :vars { + array: st-array(blue, stBorder(1px, solid, blue)); + map: st-map( + border stBorder(value(array, 1, size), + solid, + value(array, 0)) + ); + } + `, + // Stylable custom value + '/st-border.js': stBorderDefinitionMock, + }); + + const { meta } = sheets['/entry.st.css']; + const computedVars = stylable.stVar.getComputed(meta); + + expect(Object.keys(computedVars)).to.eql(['array', 'map']); + expect(computedVars.array).to.containSubset({ + value: ['blue', '1px solid blue'], + input: { + type: 'st-array', + flatValue: undefined, + value: [ + { + flatValue: undefined, + type: 'string', + value: 'blue', + }, + { + type: 'stBorder', + flatValue: '1px solid blue', + value: { + color: 'blue', + size: '1px', + style: 'solid', + }, + }, + ], + }, + diagnostics: { reports: [] }, + }); + expect(computedVars.map).to.containSubset({ + value: { + border: '1px solid blue', + }, + input: { + type: 'st-map', + flatValue: undefined, + value: { + border: { + type: 'stBorder', + flatValue: '1px solid blue', + value: { + color: 'blue', + size: '1px', + style: 'solid', + }, + }, + }, }, diagnostics: { reports: [] }, }); @@ -1363,17 +1496,30 @@ describe(`features/st-var`, () => { expect(Object.keys(computedVars)).to.eql(['imported', 'a', 'b']); expect(computedVars.imported).to.containSubset({ value: 'red', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'red', + }, diagnostics: { reports: [] }, }); expect(computedVars.a).to.containSubset({ value: 'red', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'red', + }, diagnostics: { reports: [] }, }); expect(computedVars.b).to.containSubset({ value: { a: 'red' }, - input: undefined, + input: { + type: 'st-map', + value: { + a: 'red', + }, + }, diagnostics: { reports: [] }, }); }); @@ -1396,17 +1542,29 @@ describe(`features/st-var`, () => { expect(Object.keys(computedVars)).to.eql(['validBefore', 'invalid', 'validAfter']); expect(computedVars.validBefore).to.containSubset({ value: 'red', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'red', + }, diagnostics: { reports: [] }, }); expect(computedVars.validAfter).to.containSubset({ value: 'green', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'green', + }, diagnostics: { reports: [] }, }); expect(computedVars.invalid).to.containSubset({ value: 'invalid-func(imported)', - input: undefined, + input: { + flatValue: undefined, + type: 'string', + value: 'invalid-func(imported)', + }, diagnostics: { reports: [ { @@ -1417,5 +1575,40 @@ describe(`features/st-var`, () => { }, }); }); + + it('should emit diagnostics only on invalid custom st-vars', () => { + const { stylable, sheets } = testStylableCore({ + '/entry.st.css': ` + @st-import [stBorder] from './st-border.js'; + + :vars { + border: stBorder(st-array(1px, 2px), solid, red); + } + `, + // Stylable custom value + '/st-border.js': stBorderDefinitionMock, + }); + + const { meta } = sheets['/entry.st.css']; + + const computedVars = stylable.stVar.getComputed(meta); + + expect(computedVars.border).to.containSubset({ + value: '', + input: { + flatValue: undefined, + type: 'string', + value: '', + }, + diagnostics: { + reports: [ + { + message: STVar.diagnostics.COULD_NOT_RESOLVE_VALUE(), + type: 'warning', + }, + ], + }, + }); + }); }); });