Skip to content

Commit afcc846

Browse files
committed
feat: Add style-of helper for glimmer.
There is a new synthetic helper for css-blocks in glimmer and ember applications called "style-of". Original Specification is at: linkedin#383 The parameters and hash arguments to a style-of helper invocation are analyzed as if the helper invocation is an element or component invocation. The arguments to style-of can be dynamic in the same way that attributes can be dynamic. The return value of the style-of helper is a string that can be set to an html class attribute. This value should be considered opaque. Do not try to manipulate it or depend on the value inside it. Merging the classnames from CSS Blocks with classnames from styles not managed by CSS Blocks may result in unexpected behavior, especially with opticss enabled. Closes linkedin#383.
1 parent 8a5a130 commit afcc846

File tree

11 files changed

+299
-94
lines changed

11 files changed

+299
-94
lines changed

packages/@css-blocks/glimmer/.vscode/launch.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
"request": "launch",
1111
"name": "Launch Program",
1212
"preLaunchTask": "compile",
13-
"program": "${workspaceRoot}/node_modules/.bin/_mocha",
13+
"program": "${workspaceRoot}/../../../node_modules/.bin/_mocha",
1414
"args": [
15-
"dist/test",
15+
"dist/cjs/test",
1616
"--opts",
1717
"test/mocha.opts"
1818
],

packages/@css-blocks/glimmer/src/Analyzer.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import { TemplateIntegrationOptions } from "@opticss/template-api";
1313
import * as debugGenerator from "debug";
1414
import * as fs from "fs";
1515

16-
import { ElementAnalyzer } from "./ElementAnalyzer";
17-
import { isEmberBuiltIn } from "./EmberBuiltins";
16+
import { ElementAnalyzer, isAnalyzedHelper } from "./ElementAnalyzer";
1817
import { Resolver } from "./Resolver";
1918
import { TEMPLATE_TYPE } from "./Template";
2019

@@ -124,17 +123,15 @@ export class GlimmerAnalyzer extends Analyzer<TEMPLATE_TYPE> {
124123
let elementAnalyzer = new ElementAnalyzer(analysis, this.cssBlocksOptions);
125124
traverse(ast, {
126125
MustacheStatement(node: AST.MustacheStatement) {
127-
const name = node.path.original;
128-
if (!isEmberBuiltIn(name)) { return; }
126+
if (!isAnalyzedHelper(node)) { return; }
129127
elementCount++;
130128
const atRootElement = (elementCount === 1);
131129
const element = elementAnalyzer.analyze(node, atRootElement);
132130
if (self.debug.enabled) self.debug(`{{${name}}} analyzed:`, element.class.forOptimizer(self.cssBlocksOptions).toString());
133131
},
134132

135133
BlockStatement(node: AST.BlockStatement) {
136-
const name = node.path.original;
137-
if (!isEmberBuiltIn(name)) { return; }
134+
if (!isAnalyzedHelper(node)) { return; }
138135
elementCount++;
139136
const atRootElement = (elementCount === 1);
140137
const element = elementAnalyzer.analyze(node, atRootElement);

packages/@css-blocks/glimmer/src/ClassnamesHelperGenerator.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
TernaryExpression as TernaryAST,
4242
} from "./ElementAnalyzer";
4343
import { CLASSNAMES_HELPER_NAME, CONCAT_HELPER_NAME } from "./helpers";
44-
import { isMustacheStatement } from "./utils";
44+
import { isConcatStatement, isMustacheStatement, isPathExpression, isSubExpression } from "./utils";
4545

4646
const enum SourceExpression {
4747
ternary,
@@ -228,7 +228,7 @@ function constructSwitch(builders: Builders, stateExpr: Switch<StringAST> & HasG
228228
} else {
229229
expr.push(builders.number(FalsySwitchBehavior.unset));
230230
}
231-
expr.push(moustacheToStringExpression(builders, stateExpr.stringExpression));
231+
expr.push(moustacheToStringExpression(builders, stateExpr.stringExpression!));
232232
for (let value of values) {
233233
let obj = stateExpr.group[value];
234234
expr.push(builders.string(value));
@@ -271,11 +271,11 @@ function moustacheToExpression(builders: Builders, expr: AST.MustacheStatement):
271271
}
272272
}
273273

274-
function moustacheToStringExpression(builders: Builders, stringExpression: StringAST): AST.Expression {
275-
if (stringExpression!.type === "ConcatStatement") {
274+
function moustacheToStringExpression(builders: Builders, stringExpression: Exclude<StringAST, null>): AST.Expression {
275+
if (isConcatStatement(stringExpression)) {
276276
return builders.sexpr(
277277
builders.path(CONCAT_HELPER_NAME),
278-
(stringExpression as AST.ConcatStatement).parts.reduce(
278+
stringExpression.parts.reduce(
279279
(arr, val) => {
280280
if (val.type === "TextNode") {
281281
arr.push(builders.string(val.chars));
@@ -285,8 +285,14 @@ function moustacheToStringExpression(builders: Builders, stringExpression: Strin
285285
return arr;
286286
},
287287
new Array<AST.Expression>()));
288+
} else if (isSubExpression(stringExpression)) {
289+
return stringExpression;
290+
} else if (isPathExpression(stringExpression)) {
291+
return builders.sexpr(stringExpression);
292+
} else if (isMustacheStatement(stringExpression)) {
293+
return moustacheToExpression(builders, stringExpression);
288294
} else {
289-
return moustacheToExpression(builders, stringExpression as AST.MustacheStatement);
295+
return assertNever(stringExpression);
290296
}
291297
}
292298

packages/@css-blocks/glimmer/src/ElementAnalyzer.ts

+90-48
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@css-blocks/core";
1010
import { AST, print } from "@glimmer/syntax";
1111
import { SourceLocation, SourcePosition } from "@opticss/element-analysis";
12+
import { assertNever } from "@opticss/util";
1213
import * as debugGenerator from "debug";
1314

1415
import { GlimmerAnalysis } from "./Analyzer";
@@ -20,15 +21,19 @@ import {
2021
isConcatStatement,
2122
isElementNode,
2223
isMustacheStatement,
24+
isNullLiteral,
25+
isNumberLiteral,
26+
isPathExpression,
2327
isStringLiteral,
2428
isSubExpression,
2529
isTextNode,
30+
isUndefinedLiteral,
2631
} from "./utils";
2732

2833
// Expressions may be null when ElementAnalyzer is used in the second pass analysis
2934
// to re-acquire analysis data for rewrites without storing AST nodes.
3035
export type TernaryExpression = AST.Expression | AST.MustacheStatement | null;
31-
export type StringExpression = AST.MustacheStatement | AST.ConcatStatement | null;
36+
export type StringExpression = AST.MustacheStatement | AST.ConcatStatement | AST.SubExpression | AST.PathExpression | null;
3237
export type BooleanExpression = AST.Expression | AST.MustacheStatement;
3338
export type TemplateElement = ElementAnalysis<BooleanExpression, StringExpression, TernaryExpression>;
3439
export type AttrRewriteMap = { [key: string]: TemplateElement };
@@ -41,7 +46,18 @@ const DEFAULT_BLOCK_NS = "block";
4146

4247
const debug = debugGenerator("css-blocks:glimmer:element-analyzer");
4348

44-
type AnalyzableNodes = AST.ElementNode | AST.BlockStatement | AST.MustacheStatement;
49+
type AnalyzableNode = AST.ElementNode | AST.BlockStatement | AST.MustacheStatement | AST.SubExpression;
50+
51+
export function isStyleOfHelper(node: AnalyzableNode): node is AST.MustacheStatement | AST.SubExpression {
52+
if (!isMustacheStatement(node)) return false;
53+
let name = node.path.original;
54+
return typeof name === "string" && name === "style-of";
55+
}
56+
57+
export function isAnalyzedHelper(node: AnalyzableNode): node is AST.MustacheStatement | AST.BlockStatement {
58+
if (isElementNode(node)) return false;
59+
return isEmberBuiltIn(node.path.original) || isStyleOfHelper(node);
60+
}
4561

4662
export class ElementAnalyzer {
4763
analysis: GlimmerAnalysis;
@@ -58,15 +74,15 @@ export class ElementAnalyzer {
5874
this.reservedClassNames = analysis.reservedClassNames();
5975
}
6076

61-
analyze(node: AnalyzableNodes, atRootElement: boolean): AttrRewriteMap {
77+
analyze(node: AnalyzableNode, atRootElement: boolean): AttrRewriteMap {
6278
return this._analyze(node, atRootElement, false);
6379
}
6480

65-
analyzeForRewrite(node: AnalyzableNodes, atRootElement: boolean): AttrRewriteMap {
81+
analyzeForRewrite(node: AnalyzableNode, atRootElement: boolean): AttrRewriteMap {
6682
return this._analyze(node, atRootElement, true);
6783
}
6884

69-
private debugAnalysis(node: AnalyzableNodes, atRootElement: boolean, element: TemplateElement) {
85+
private debugAnalysis(node: AnalyzableNode, atRootElement: boolean, element: TemplateElement) {
7086
if (!debug.enabled) return;
7187
let startTag = "";
7288
if (isElementNode(node)) {
@@ -80,15 +96,15 @@ export class ElementAnalyzer {
8096
debug(`↳ Analyzed as: ${element.forOptimizer(this.cssBlocksOpts)[0].toString()}`);
8197
}
8298

83-
private debugTemplateLocation(node: AnalyzableNodes) {
99+
private debugTemplateLocation(node: AnalyzableNode) {
84100
let templatePath = this.cssBlocksOpts.importer.debugIdentifier(this.template.identifier, this.cssBlocksOpts);
85101
return charInFile(templatePath, node.loc.start);
86102
}
87103
private debugBlockPath(block: Block | null = null) {
88104
return this.cssBlocksOpts.importer.debugIdentifier((block || this.block).identifier, this.cssBlocksOpts);
89105
}
90106

91-
private newElement(node: AnalyzableNodes, forRewrite: boolean): TemplateElement {
107+
private newElement(node: AnalyzableNode, forRewrite: boolean): TemplateElement {
92108
let label = isElementNode(node) ? node.tag : node.path.original as string;
93109
if (forRewrite) {
94110
return new ElementAnalysis<BooleanExpression, StringExpression, TernaryExpression>(nodeLocation(node), this.reservedClassNames, label);
@@ -117,8 +133,26 @@ export class ElementAnalyzer {
117133
}
118134
}
119135

136+
*eachAnalyzedAttribute(node: AnalyzableNode): Iterable<[string, string, AST.AttrNode | AST.HashPair]> {
137+
if (isElementNode(node)) {
138+
for (let attribute of node.attributes) {
139+
let [namespace, attrName] = this.isAttributeAnalyzed(attribute.name);
140+
if (namespace && attrName) {
141+
yield [namespace, attrName, attribute];
142+
}
143+
}
144+
} else {
145+
for (let pair of node.hash.pairs) {
146+
let [namespace, attrName] = this.isAttributeAnalyzed(pair.key);
147+
if (namespace && attrName) {
148+
yield [namespace, attrName, pair];
149+
}
150+
}
151+
}
152+
}
153+
120154
private _analyze(
121-
node: AnalyzableNodes,
155+
node: AnalyzableNode,
122156
atRootElement: boolean,
123157
forRewrite: boolean,
124158
): AttrRewriteMap {
@@ -131,53 +165,34 @@ export class ElementAnalyzer {
131165
element.addStaticClass(this.block.rootClass);
132166
}
133167

134-
// Find the class attribute and process.
135-
if (isElementNode(node)) {
136-
for (let attribute of node.attributes) {
137-
let [namespace, attrName] = this.isAttributeAnalyzed(attribute.name);
138-
if (namespace && attrName) {
139-
if (attrName === "class") {
140-
this.processClass(namespace, attribute, element, forRewrite);
141-
} else if (attrName === "scope") {
142-
this.processScope(namespace, attribute, element, forRewrite);
143-
}
144-
}
145-
}
146-
}
147-
else {
148-
for (let pair of node.hash.pairs) {
149-
if (pair.key === "class") {
150-
throw cssBlockError(`The class attribute is forbidden. Did you mean block:class?`, node, this.template);
151-
}
152-
let [namespace, attrName] = this.isAttributeAnalyzed(pair.key);
153-
if (namespace && attrName) {
154-
if (attrName === "class") {
155-
this.processClass(namespace, pair, element, forRewrite);
156-
} else if (attrName === "scope") {
157-
this.processScope(namespace, pair, element, forRewrite);
158-
}
159-
}
168+
// Find the class or scope attribute and process it
169+
for (let [namespace, attrName, attribute] of this.eachAnalyzedAttribute(node)) {
170+
if (attrName === "class") {
171+
this.processClass(namespace, attribute, element, forRewrite);
172+
} else if (attrName === "scope") {
173+
this.processScope(namespace, attribute, element, forRewrite);
160174
}
161175
}
162176

163-
// Only ElementNodes may use states right now.
177+
// validate that html elements aren't using the class attribute.
164178
if (isElementNode(node)) {
165179
for (let attribute of node.attributes) {
166180
if (attribute.name === "class") {
167181
throw cssBlockError(`The class attribute is forbidden. Did you mean block:class?`, node, this.template);
168182
}
169-
let [namespace, attrName] = this.isAttributeAnalyzed(attribute.name);
170-
if (namespace && attrName) {
171-
if (attrName !== "class" && attrName !== "scope") {
172-
this.processState(namespace, attrName, attribute, element, forRewrite);
173-
}
174-
}
183+
}
184+
}
185+
186+
for (let [namespace, attrName, attribute] of this.eachAnalyzedAttribute(node)) {
187+
if (namespace && attrName) {
188+
if (attrName === "class" || attrName === "scope") continue;
189+
this.processState(namespace, attrName, attribute, element, forRewrite);
175190
}
176191
}
177192

178193
this.finishElement(element, forRewrite);
179194

180-
// If this is an Ember Build-In...
195+
// If this is an Ember Built-In...
181196
if (!isElementNode(node) && isEmberBuiltIn(node.path.original)) {
182197
this.debugAnalysis(node, atRootElement, element);
183198

@@ -335,7 +350,7 @@ export class ElementAnalyzer {
335350
private processState(
336351
blockName: string,
337352
stateName: string,
338-
node: AST.AttrNode,
353+
node: AST.AttrNode | AST.HashPair,
339354
element: TemplateElement,
340355
forRewrite: boolean,
341356
): void {
@@ -345,17 +360,44 @@ export class ElementAnalyzer {
345360
throw cssBlockError(`No block or class from ${blockName || "the default block"} is assigned to the element so a state from that block cannot be used.`, node, this.template);
346361
}
347362
let staticSubStateName: string | undefined = undefined;
348-
let dynamicSubState: AST.MustacheStatement | AST.ConcatStatement | undefined = undefined;
349-
if (isTextNode(node.value)) {
350-
staticSubStateName = node.value.chars;
363+
let dynamicSubState: AST.MustacheStatement | AST.ConcatStatement | AST.SubExpression | AST.PathExpression | undefined = undefined;
364+
let value = node.value;
365+
if (isTextNode(value)) {
366+
staticSubStateName = value.chars;
367+
if (staticSubStateName === "") {
368+
staticSubStateName = undefined;
369+
}
370+
} else if (isStringLiteral(value)) {
371+
staticSubStateName = value.value;
351372
if (staticSubStateName === "") {
352373
staticSubStateName = undefined;
353374
}
375+
} else if (isNumberLiteral(value)) {
376+
staticSubStateName = value.value.toString();
377+
if (staticSubStateName === "") {
378+
staticSubStateName = undefined;
379+
}
380+
} else if (isBooleanLiteral(value)) {
381+
if (!value.value) {
382+
// Setting the state explicitly to false is the same as not having the state on the element.
383+
// So we just skip analysis of it. In the future we might want to partially analyze it to validate
384+
// that the state name exists
385+
return;
386+
// Setting it to true is the simplest way to set the state having no substates on an element when using the style-of helper.
387+
}
388+
} else if (isMustacheStatement(value) || isConcatStatement(value) || isSubExpression(value) || isPathExpression(value)) {
389+
dynamicSubState = value;
390+
} else if (isNullLiteral(value) || isUndefinedLiteral(value)) {
391+
// Setting the state explicitly to null or undefined is the same as not having the state on the element.
392+
// So we just skip analysis of it. In the future we might want to partially analyze it to validate
393+
// that the state name exists
394+
return;
354395
} else {
355-
dynamicSubState = node.value;
396+
assertNever(value);
356397
}
398+
357399
let found = false;
358-
const errors: [string, AST.AttrNode, ResolvedFile][] = [];
400+
const errors: [string, AST.AttrNode | AST.HashPair, ResolvedFile][] = [];
359401
for (let container of containers) {
360402
let stateGroup = container.resolveAttribute({
361403
namespace: "state",

packages/@css-blocks/glimmer/src/EmberBuiltins.ts

+13
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { AST } from "@glimmer/syntax";
2+
3+
import { isBlockStatement, isMustacheStatement } from "./utils";
4+
15
/**
26
* Ember Built-Ins are components that should be analyzed like regular
37
* elements. The Analyzer and Rewriter will process components defined
@@ -21,6 +25,15 @@ const BUILT_INS: IBuiltIns = {
2125

2226
export type BuiltIns = keyof IBuiltIns;
2327

28+
export function isEmberBuiltInNode(node: AST.Node): node is AST.BlockStatement | AST.MustacheStatement {
29+
if (isBlockStatement(node) || isMustacheStatement(node)) {
30+
let name = node.path.original;
31+
if (typeof name === "string" && BUILT_INS[name]) {
32+
return true;
33+
}
34+
}
35+
return false;
36+
}
2437
export function isEmberBuiltIn(name: unknown): name is keyof IBuiltIns {
2538
if (typeof name === "string" && BUILT_INS[name]) { return true; }
2639
return false;

0 commit comments

Comments
 (0)