Skip to content

Commit 5df7a7e

Browse files
committed
feat: Allow styles to be set to className properties for dynamic change to the class attribute.
1 parent 3a1e9f3 commit 5df7a7e

File tree

10 files changed

+245
-43
lines changed

10 files changed

+245
-43
lines changed

packages/jsx-analyzer/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"preact": "^8.2.1",
6262
"prettier": "^1.8.2",
6363
"shelljs": "^0.7.8",
64+
"test-console": "^1.1.0",
6465
"ts-node": "^3.0.4",
6566
"tslint": "^5.5.0",
6667
"watch": "^1.0.2"
@@ -71,14 +72,14 @@
7172
"dependencies": {
7273
"@opticss/template-api": "^0.1.0",
7374
"@opticss/util": "^0.1.0",
74-
"opticss": "^0.1.0",
75-
"css-blocks": "^0.15.0-rc.0",
7675
"babel-traverse": "^6.24.1",
7776
"babel-types": "^6.24.1",
7877
"babylon": "^6.17.4",
78+
"css-blocks": "^0.15.0-rc.0",
7979
"debug": "^2.6.8",
8080
"minimatch": "^3.0.4",
8181
"object.values": "^1.0.4",
82+
"opticss": "^0.1.0",
8283
"typescript": "^2.3.4"
8384
}
8485
}

packages/jsx-analyzer/src/analyzer/JSXElementAnalyzer.ts

+50-21
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
JSXAttribute,
2020
Identifier,
2121
SourceLocation,
22+
AssignmentExpression,
2223
} from 'babel-types';
2324

2425
import { MalformedBlockPath, TemplateAnalysisError } from '../utils/Errors';
@@ -59,7 +60,24 @@ export class JSXElementAnalyzer {
5960
return found;
6061
}
6162

62-
analyze(path: NodePath<JSXOpeningElement>): JSXElementAnalysis | undefined {
63+
analyzeAssignment(path: NodePath<AssignmentExpression>): JSXElementAnalysis | undefined {
64+
let assignment = path.node as AssignmentExpression;
65+
if (assignment.operator !== '=') return;
66+
let lVal = assignment.left;
67+
if (isMemberExpression(lVal)) {
68+
let property = lVal.property;
69+
if (!lVal.computed && isIdentifier(property) && property.name === 'className') {
70+
let element = newJSXElementAnalysis(this.location(path));
71+
this.analyzeClassExpression(path.get('right') as NodePath<Expression>, element);
72+
if (element.hasStyles()) {
73+
return element;
74+
}
75+
}
76+
}
77+
return;
78+
}
79+
80+
analyzeJSXElement(path: NodePath<JSXOpeningElement>): JSXElementAnalysis | undefined {
6381
let el = path.node;
6482

6583
// We don't care about elements with no attributes;
@@ -121,16 +139,13 @@ export class JSXElementAnalyzer {
121139
return;
122140
}
123141

124-
private analyzeClassAttribute(path: NodePath<JSXAttribute>, element: JSXElementAnalysis): void {
125-
let value = path.node.value;
126-
if (!isJSXExpressionContainer(value)) return; // should this be an error?
127-
// If this attribute's value is an expression, evaluate it for block references.
128-
// Discover block root identifiers.
129-
if (isIdentifier(value.expression)) {
130-
let identifier = value.expression;
131-
let identBinding = path.scope.getBinding(identifier.name);
142+
private analyzeClassExpression(expression: NodePath<Expression>, element: JSXElementAnalysis, suppressErrors = false): void {
143+
if (expression.isIdentifier()) {
144+
let identifier = expression.node as Identifier;
145+
let identBinding = expression.scope.getBinding(identifier.name);
132146
if (identBinding) {
133147
if (identBinding.constantViolations.length > 0) {
148+
if (suppressErrors) return;
134149
throw new TemplateAnalysisError(`illegal assignment to a style variable.`, this.nodeLoc(identBinding.constantViolations[0]));
135150
}
136151
if (identBinding.kind === 'module') {
@@ -140,7 +155,8 @@ export class JSXElementAnalyzer {
140155
if (block) {
141156
element.addStaticClass(block);
142157
} else {
143-
throw new TemplateAnalysisError(`No block named ${name} was found`, this.nodeLoc(value));
158+
if (suppressErrors) return;
159+
throw new TemplateAnalysisError(`No block named ${name} was found`, this.nodeLoc(expression));
144160
}
145161
} else {
146162
let identPathNode = identBinding.path.node;
@@ -151,34 +167,37 @@ export class JSXElementAnalyzer {
151167
for (let refPath of identBinding.referencePaths.filter(p => p.parentPath.type !== 'JSXExpressionContainer')) {
152168
let parentPath = refPath.parentPath;
153169
if (!isConsoleLogStatement(parentPath.node)) {
170+
if (suppressErrors) return;
154171
throw new TemplateAnalysisError(`illegal use of a style variable.`, this.nodeLoc(parentPath));
155172
}
156173
}
157174
}
158175
} else {
159-
throw new TemplateAnalysisError(`variable for class attributes must be initialized with a style expression.`, this.nodeLoc(value));
176+
if (suppressErrors) return;
177+
throw new TemplateAnalysisError(`variable for class attributes must be initialized with a style expression.`, this.nodeLoc(expression));
160178
}
161179
this.addPossibleDynamicStyles(element, initialValueOfIdent, identBinding.path);
162180
}
163181
}
164-
} else if (isMemberExpression(value.expression)) {
182+
} else if (expression.isMemberExpression()) {
165183
// Discover direct references to an imported block.
166184
// Ex: `blockName.foo` || `blockName['bar']` || `blockName.bar()`
167-
let parts: ExpressionReader = new ExpressionReader(value.expression, this.filename);
185+
let parts: ExpressionReader = new ExpressionReader(expression.node, this.filename);
168186
let expressionResult = parts.getResult(this.blocks);
169187
let blockOrClass = expressionResult.blockClass || expressionResult.block;
170188
if (isBlockStateGroupResult(expressionResult) || isBlockStateResult(expressionResult)) {
171189
throw new Error('internal error, not expected on a member expression');
172190
} else {
173191
element.addStaticClass(blockOrClass);
174192
}
175-
} else if (isCallExpression(value.expression)) {
176-
let styleFn = isStyleFunction(path, value.expression);
193+
} else if (expression.isCallExpression()) {
194+
let callExpr = expression.node as CallExpression;
195+
let styleFn = isStyleFunction(expression, callExpr);
177196
if (styleFn.type === 'error') {
178197
if (styleFn.canIgnore) {
179198
// It's not a style helper function, assume it's a static reference to a state.
180199
try {
181-
let parts: ExpressionReader = new ExpressionReader(value.expression, this.filename);
200+
let parts: ExpressionReader = new ExpressionReader(callExpr, this.filename);
182201
let expressionResult = parts.getResult(this.blocks);
183202
let blockOrClass = expressionResult.blockClass || expressionResult.block;
184203
if (isBlockStateGroupResult(expressionResult)) {
@@ -190,28 +209,38 @@ export class JSXElementAnalyzer {
190209
}
191210
} catch (e) {
192211
if (e instanceof MalformedBlockPath) {
193-
if (isIdentifier(value.expression.callee)) {
194-
let fnName = value.expression.callee.name;
212+
if (isIdentifier(callExpr.callee)) {
213+
let fnName = callExpr.callee.name;
195214
if (isCommonNameForStyling(fnName)) {
196-
throw new TemplateAnalysisError(`The call to style function '${fnName}' does not resolve to an import statement of a known style helper.`, this.nodeLoc(value.expression));
215+
throw new TemplateAnalysisError(`The call to style function '${fnName}' does not resolve to an import statement of a known style helper.`, this.nodeLoc(expression));
197216
} else {
198-
throw new TemplateAnalysisError(`Function called within class attribute value '${fnName}' must be either an 'objstr' call, or a state reference`, this.nodeLoc(value.expression));
217+
throw new TemplateAnalysisError(`Function called within class attribute value '${fnName}' must be either an 'objstr' call, or a state reference`, this.nodeLoc(expression));
199218
}
200219
}
201220
}
202221
throw e;
203222
}
204223
} else {
224+
if (suppressErrors) return;
205225
throw new TemplateAnalysisError(styleFn.message, styleFn.location);
206226
}
207227
} else {
208-
styleFn.analyze(this.blocks, element, this.filename, styleFn, value.expression);
228+
styleFn.analyze(this.blocks, element, this.filename, styleFn, callExpr);
209229
}
210230
} else {
211231
// TODO handle ternary expressions like style-if in handlebars?
212232
}
213233
}
214234

235+
private analyzeClassAttribute(path: NodePath<JSXAttribute>, element: JSXElementAnalysis): void {
236+
let value = path.get('value');
237+
if (!value.isJSXExpressionContainer()) return; // should this be an error?
238+
// If this attribute's value is an expression, evaluate it for block references.
239+
// Discover block root identifiers.
240+
let expressionPath = value.get('expression') as NodePath<Expression>;
241+
this.analyzeClassExpression(expressionPath, element);
242+
}
243+
215244
/**
216245
* Given a well formed style expression `CallExpression`, add all Block style references
217246
* to the given analysis object.

packages/jsx-analyzer/src/analyzer/index.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NodePath } from 'babel-traverse';
2-
import { JSXOpeningElement, } from 'babel-types';
2+
import { JSXOpeningElement, AssignmentExpression } from 'babel-types';
33

44
import Analysis from '../utils/Analysis';
55
import { JSXElementAnalyzer } from './JSXElementAnalyzer';
@@ -12,6 +12,10 @@ export default function visitors(analysis: Analysis): object {
1212
let elementAnalyzer = new JSXElementAnalyzer(analysis.blocks, analysis.template.identifier);
1313

1414
return {
15+
AssignmentExpression(path: NodePath<AssignmentExpression>): void {
16+
let element = elementAnalyzer.analyzeAssignment(path);
17+
if (element) analysis.addElement(element);
18+
},
1519
// TODO: handle the `h()` function?
1620

1721
/**
@@ -20,7 +24,7 @@ export default function visitors(analysis: Analysis): object {
2024
* @param path The JSXOpeningElement Babylon path we are processing.
2125
*/
2226
JSXOpeningElement(path: NodePath<JSXOpeningElement>): void {
23-
let element = elementAnalyzer.analyze(path);
27+
let element = elementAnalyzer.analyzeJSXElement(path);
2428
if (element) analysis.addElement(element);
2529
}
2630
};

packages/jsx-analyzer/src/styleFunctions/objstrFunction.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ export function analyzeObjstr(blocks: ObjectDictionary<Block>, element: JSXEleme
118118
throw new TemplateAnalysisError('The spread operator is not allowed in CSS Block states.', {filename, ...result.dynamicStateExpression.loc.start});
119119
} else {
120120
// if truthy, the only dynamic expr is from the state selector.
121-
// TODO: would like to force this to be an error if none provided.
122-
element.addDynamicGroup(result.blockClass || result.block, result.stateGroup, result.dynamicStateExpression, false);
121+
element.addDynamicGroup(result.blockClass || result.block, result.stateGroup, result.dynamicStateExpression, true);
123122
}
124123
} // else ignore
125124
} else {

packages/jsx-analyzer/src/transformer/babel.ts

+47-6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
isJSXExpressionContainer,
2020
JSXAttribute,
2121
Node,
22+
AssignmentExpression,
23+
Identifier,
24+
Expression,
2225
} from 'babel-types';
2326

2427
import isBlockFilename from '../utils/isBlockFilename';
@@ -95,10 +98,36 @@ export default function mkTransform(tranformOpts: { rewriter: Rewriter }): () =>
9598
}
9699
},
97100

101+
AssignmentExpression(path: NodePath<AssignmentExpression>): void {
102+
if (!this.shouldProcess) return;
103+
104+
let elementAnalysis = this.elementAnalyzer.analyzeAssignment(path);
105+
if (elementAnalysis) {
106+
elementAnalysis.seal();
107+
let classMapping = this.mapping.simpleRewriteMapping(elementAnalysis);
108+
let className: Expression | undefined = undefined;
109+
if (classMapping.dynamicClasses.length > 0) {
110+
className = generateClassName(classMapping, elementAnalysis, HELPER_FN_NAME, true);
111+
} else {
112+
className = stringLiteral(classMapping.staticClasses.join(' '));
113+
}
114+
let right = path.get('right');
115+
if (right.isIdentifier()) {
116+
let binding = right.scope.getBinding((<Identifier>right.node).name);
117+
if (binding && binding.path.isVariableDeclarator()) {
118+
let init = binding.path.get('init');
119+
init.replaceWith(className);
120+
return;
121+
}
122+
}
123+
right.replaceWith(className);
124+
}
125+
},
126+
98127
JSXOpeningElement(path: NodePath<JSXOpeningElement>, state: any): void {
99128
if (!this.shouldProcess) return;
100129

101-
let elementAnalysis = this.elementAnalyzer.analyze(path);
130+
let elementAnalysis = this.elementAnalyzer.analyzeJSXElement(path);
102131
if (elementAnalysis) {
103132
elementAnalysis.seal();
104133
let classMapping = this.mapping.simpleRewriteMapping(elementAnalysis);
@@ -152,8 +181,10 @@ function detectStrayReferenceToImport(
152181
let binding = importDeclPath.scope.getBinding(specifier.local.name);
153182
if (binding) {
154183
for (let ref of binding.referencePaths) {
155-
if (!isRemoved(ref)) {
156-
console.warn(`WARNING: Stray reference to block import (${specifier.local.name}). Imports are removed during rewrite so this will probably be a runtime error. (${filename}:${ref.node.loc.start.line}:${ref.node.loc.start.column}`);
184+
if (ref.type === 'Identifier'
185+
&& (<Identifier>ref.node).name === specifier.local.name
186+
&& !isRemoved(ref)) {
187+
console.warn(`WARNING: Stray reference to block import (${specifier.local.name}). Imports are removed during rewrite so this will probably be a runtime error. (${filename}:${ref.node.loc.start.line}:${ref.node.loc.start.column})`);
157188
// throw new TemplateAnalysisError(`Stray reference to block import (${specifier.local.name}). Imports are removed during rewrite.`, {filename, ...ref.node.loc.start});
158189
}
159190
}
@@ -162,10 +193,20 @@ function detectStrayReferenceToImport(
162193
}
163194

164195
function isRemoved(path: NodePath<Node>): boolean {
165-
if (path.removed) return true;
166-
let p = path.parentPath;
196+
let p = path;
167197
while (p && p.type !== 'Program') {
168-
if (p.removed) return true;
198+
if (p.removed || p.parentPath.removed) return true;
199+
if (p.inList) {
200+
let list = p.parentPath.get(p.listKey);
201+
if (!Array.isArray(list)) return true;
202+
let element = list[p.key];
203+
if (!element) return true;
204+
if (element.node !== p.node) return true;
205+
} else {
206+
if (p.parentPath.get(p.parentKey).node !== p.node) {
207+
return true;
208+
}
209+
}
169210
p = p.parentPath;
170211
}
171212
return false;

packages/jsx-analyzer/test/analyzer/external-objstr-class-test.ts

+47
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,53 @@ export class Test {
1111
mock.restore();
1212
}
1313

14+
@test 'Can set className dynamically'() {
15+
mock({
16+
'bar.block.css': `
17+
.root { color: red; }
18+
.foo { color: blue; }
19+
.foo[state|happy] { color: balloons; }
20+
`
21+
});
22+
23+
return parse(`
24+
import bar from 'bar.block.css'
25+
import objstr from 'obj-str';
26+
27+
function doesSomething(element) {
28+
element.className = objstr({
29+
[bar.foo]: true,
30+
});
31+
let style = objstr({
32+
[bar.foo]: true,
33+
[bar.foo.happy()]: true
34+
});
35+
element.className = bar;
36+
element.className = style;
37+
}
38+
`
39+
).then((metaAnalysis: MetaAnalysis) => {
40+
let result = metaAnalysis.serialize();
41+
let analysis = result.analyses[0];
42+
assert.deepEqual(analysis.stylesFound, ['bar.foo', 'bar.foo[state|happy]', 'bar.root']);
43+
44+
let aAnalysis = analysis.elements.a;
45+
assert.deepEqual(aAnalysis.dynamicClasses, []);
46+
assert.deepEqual(aAnalysis.dynamicStates, []);
47+
assert.deepEqual(aAnalysis.staticStyles, [0]);
48+
49+
let bAnalysis = analysis.elements.b;
50+
assert.deepEqual(bAnalysis.dynamicClasses, []);
51+
assert.deepEqual(bAnalysis.dynamicStates, []);
52+
assert.deepEqual(bAnalysis.staticStyles, [2]);
53+
54+
let cAnalysis = analysis.elements.c;
55+
assert.deepEqual(cAnalysis.dynamicClasses, []);
56+
assert.deepEqual(cAnalysis.dynamicStates, []);
57+
assert.deepEqual(cAnalysis.staticStyles, [0, 1]);
58+
});
59+
}
60+
1461
@test 'Classes on objstr calls are tracked when applied'() {
1562
mock({
1663
'bar.block.css': '.root { color: red; } .foo { color: blue; }'

0 commit comments

Comments
 (0)