Skip to content

Commit 053ed47

Browse files
committed
feat: Per block namespaces.
RFC: #332 BREAKING CHANGE Ember template syntax for states now uses the block's name as the namespace instead of the single namespace of 'state'. The default namespace class attribute is now forbidden because the class attribute is supposed to be namespaced like state attributes are. A new namespaced attribute 'scope' is used to apply the block root class to an element.
1 parent f8e5071 commit 053ed47

File tree

12 files changed

+140
-88
lines changed

12 files changed

+140
-88
lines changed

packages/@css-blocks/glimmer/README.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Given the following CSS Block definition:
3131
block-name: my-component;
3232
/* ... */
3333
}
34-
[loading] { /* ... */ }
34+
:scope[loading] { /* ... */ }
3535
.sidebar { /* ... */ }
3636
.sidebar[collapsed] { /* ... */ }
3737
.main { /* ... */ }
@@ -47,10 +47,10 @@ Given the following CSS Block definition:
4747
We can style a glimmer template like so:
4848

4949
```hbs
50-
<div state:loading={{isLoading}}>
51-
<aside class="sidebar grid.one-fifth" state:collapsed state:grid.gutter-right>
50+
<div block:scope block:loading={{isLoading}}>
51+
<aside block:class="sidebar" grid:class="one-fifth" block:collapsed grid:gutter="right">
5252
</aside>
53-
<article class="{{style-if isRecommended 'recommended' 'main'}} grid.four-fifths">
53+
<article class="{{style-if isRecommended 'recommended' 'main'}}" grid:class="four-fifths">
5454
</article>
5555
</div>
5656
```

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

+85-36
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export type BooleanExpression = AST.Expression | AST.MustacheStatement;
2323
export type TemplateElement = ElementAnalysis<BooleanExpression, StringExpression, TernaryExpression>;
2424
export type AttrRewriteMap = { [key: string]: TemplateElement };
2525

26-
// TODO: The state namespace should come from a config option.
27-
const STATE = /^state:(?:([^.]+)\.)?([^.]+)$/;
26+
const NAMESPACED_ATTR = /^([^:]+):([^:]+)$/;
2827
const STYLE_IF = "style-if";
2928
const STYLE_UNLESS = "style-unless";
3029
const DEFAULT_BLOCK_NAME = "default";
30+
const DEFAULT_BLOCK_NS = "block";
3131

3232
const debug = debugGenerator("css-blocks:glimmer:element-analyzer");
3333

@@ -72,8 +72,8 @@ export class ElementAnalyzer {
7272
let templatePath = this.cssBlocksOpts.importer.debugIdentifier(this.template.identifier, this.cssBlocksOpts);
7373
return charInFile(templatePath, node.loc.start);
7474
}
75-
private debugBlockPath() {
76-
return this.cssBlocksOpts.importer.debugIdentifier(this.block.identifier, this.cssBlocksOpts);
75+
private debugBlockPath(block: Block | null = null) {
76+
return this.cssBlocksOpts.importer.debugIdentifier((block || this.block).identifier, this.cssBlocksOpts);
7777
}
7878

7979
private newElement(node: AnalyzableNodes, forRewrite: boolean): TemplateElement {
@@ -91,6 +91,16 @@ export class ElementAnalyzer {
9191
if (!forRewrite) { this.analysis.endElement(element); }
9292
}
9393

94+
isAttributeAnalyzed(attributeName: string): [string, string] | [null, null] {
95+
if (NAMESPACED_ATTR.test(attributeName)) {
96+
let namespace = RegExp.$1;
97+
let attrName = RegExp.$2;
98+
return [namespace, attrName];
99+
} else {
100+
return [null, null];
101+
}
102+
}
103+
94104
private _analyze(
95105
node: AnalyzableNodes,
96106
atRootElement: boolean,
@@ -106,21 +116,43 @@ export class ElementAnalyzer {
106116
}
107117

108118
// Find the class attribute and process.
109-
if (node.type === "ElementNode") {
110-
let classAttr: AST.AttrNode | undefined = node.attributes.find(n => n.name === "class");
111-
if (classAttr) { this.processClass(classAttr, element, forRewrite); }
119+
if (isElementNode(node)) {
120+
for (let attribute of node.attributes) {
121+
let [namespace, attrName] = this.isAttributeAnalyzed(attribute.name);
122+
if (namespace && attrName) {
123+
if (attrName === "class") {
124+
this.processClass(namespace, attribute, element, forRewrite);
125+
} else if (attrName === "scope") {
126+
this.processScope(namespace, attribute, element, forRewrite);
127+
}
128+
}
129+
}
112130
}
113-
114131
else {
115-
let classAttr: AST.HashPair | undefined = node.hash.pairs.find(n => n.key === "class");
116-
if (classAttr) { this.processClass(classAttr, element, forRewrite); }
132+
for (let pair of node.hash.pairs) {
133+
let [namespace, attrName] = this.isAttributeAnalyzed(pair.key);
134+
if (namespace && attrName) {
135+
if (attrName === "class") {
136+
this.processClass(namespace, pair, element, forRewrite);
137+
} else if (attrName === "scope") {
138+
this.processScope(namespace, pair, element, forRewrite);
139+
}
140+
}
141+
}
117142
}
118143

119144
// Only ElementNodes may use states right now.
120145
if (isElementNode(node)) {
121146
for (let attribute of node.attributes) {
122-
if (!STATE.test(attribute.name)) { continue; }
123-
this.processState(RegExp.$1, RegExp.$2, attribute, element, forRewrite);
147+
if (attribute.name === "class") {
148+
throw cssBlockError(`The class attribute is forbidden. Did you mean block:class?`, node, this.template);
149+
}
150+
let [namespace, attrName] = this.isAttributeAnalyzed(attribute.name);
151+
if (namespace && attrName) {
152+
if (attrName !== "class" && attrName !== "scope") {
153+
this.processState(namespace, attrName, attribute, element, forRewrite);
154+
}
155+
}
124156
}
125157
}
126158

@@ -151,35 +183,36 @@ export class ElementAnalyzer {
151183
return attrRewrites;
152184
}
153185

154-
private lookupClasses(classes: string, node: AST.Node): Array<BlockClass> {
186+
private lookupClasses(namespace: string, classes: string, node: AST.Node): Array<BlockClass> {
155187
let classNames = classes.trim().split(/\s+/);
156188
let found = new Array<BlockClass>();
157189
for (let name of classNames) {
158-
found.push(this.lookupClass(name, node));
190+
found.push(this.lookupClass(namespace, name, node));
159191
}
160192
return found;
161193
}
162194

163-
private lookupClass(name: string, node: AST.Node): BlockClass {
164-
let found = this.block.externalLookup(name);
165-
if (!found && !/\./.test(name)) {
166-
found = this.block.externalLookup("." + name);
195+
private lookupBlock(namespace: string, node: AST.Node): Block {
196+
let block = (namespace === DEFAULT_BLOCK_NS) ? this.block : this.block.getExportedBlock(namespace);
197+
if (block === null) {
198+
throw cssBlockError(`No block '${namespace}' is exported from ${this.debugBlockPath()}`, node, this.template);
167199
}
168-
if (found) {
169-
return <BlockClass>found;
170-
} else {
171-
if (/\./.test(name)) {
172-
throw cssBlockError(`No class or block named ${name} is referenced from ${this.debugBlockPath()}`, node, this.template);
173-
} else {
174-
throw cssBlockError(`No class or block named ${name}`, node, this.template);
175-
}
200+
return block;
201+
}
202+
203+
private lookupClass(namespace: string, name: string, node: AST.Node): BlockClass {
204+
let block = this.lookupBlock(namespace, node);
205+
let found = block.resolveClass(name);
206+
if (found === null) {
207+
throw cssBlockError(`No class '${name}' was found in block at ${this.debugBlockPath(block)}`, node, this.template);
176208
}
209+
return found;
177210
}
178211

179212
/**
180213
* Adds blocks and block classes to the current node from the class attribute.
181214
*/
182-
private processClass(node: AST.AttrNode | AST.HashPair, element: TemplateElement, forRewrite: boolean): void {
215+
private processClass(namespace: string, node: AST.AttrNode | AST.HashPair, element: TemplateElement, forRewrite: boolean): void {
183216
let statements: AST.Node[];
184217

185218
let value = node.value;
@@ -193,7 +226,7 @@ export class ElementAnalyzer {
193226
for (let statement of statements) {
194227
if (isTextNode(statement) || isStringLiteral(statement)) {
195228
let value = isTextNode(statement) ? statement.chars : statement.value;
196-
for (let container of this.lookupClasses(value, statement)) {
229+
for (let container of this.lookupClasses(namespace, value, statement)) {
197230
element.addStaticClass(container);
198231
}
199232
}
@@ -210,7 +243,7 @@ export class ElementAnalyzer {
210243

211244
// Calculate the classes in the main branch of the style helper
212245
if (isStringLiteral(mainBranch)) {
213-
let containers = this.lookupClasses(mainBranch.value, mainBranch);
246+
let containers = this.lookupClasses(namespace, mainBranch.value, mainBranch);
214247
if (helperType === "style-if") {
215248
whenTrue = containers;
216249
} else {
@@ -223,7 +256,7 @@ export class ElementAnalyzer {
223256
// Calculate the classes in the else branch of the style helper, if it exists.
224257
if (elseBranch) {
225258
if (isStringLiteral(elseBranch)) {
226-
let containers = this.lookupClasses(elseBranch.value, elseBranch);
259+
let containers = this.lookupClasses(namespace, elseBranch.value, elseBranch);
227260
if (helperType === "style-if") {
228261
whenFalse = containers;
229262
} else {
@@ -247,21 +280,34 @@ export class ElementAnalyzer {
247280
}
248281
}
249282
}
283+
private processScope(namespace: string, node: AST.AttrNode | AST.HashPair, element: TemplateElement, _forRewrite: boolean): void {
284+
let value = node.value;
285+
let block = this.lookupBlock(namespace, node);
286+
287+
if (isTextNode(value)) {
288+
if (value.chars === "") {
289+
element.addStaticClass(block.rootClass);
290+
} else {
291+
throw cssBlockError("String literal values are not allowed for the scope attribute", node, this.template);
292+
}
293+
} else if (isBooleanLiteral(value)) {
294+
if (value.value) {
295+
element.addStaticClass(block.rootClass);
296+
}
297+
}
298+
}
250299

251300
/**
252301
* Adds states to the current node.
253302
*/
254303
private processState(
255-
blockName: string | undefined,
304+
blockName: string,
256305
stateName: string,
257306
node: AST.AttrNode,
258307
element: TemplateElement,
259308
forRewrite: boolean,
260309
): void {
261-
let stateBlock = blockName ? this.block.getExportedBlock(blockName) : this.block;
262-
if (stateBlock === null) {
263-
throw cssBlockError(`No block named ${blockName} referenced from ${this.debugBlockPath()}`, node, this.template);
264-
}
310+
let stateBlock = this.lookupBlock(blockName, node);
265311
let containers = element.classesForBlock(stateBlock);
266312
if (containers.length === 0) {
267313
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);
@@ -322,7 +368,7 @@ export class ElementAnalyzer {
322368
if (staticSubStateName) {
323369
errors.push([`No state found named ${stateName} with a sub-state of ${staticSubStateName} for ${container.asSource()} in ${blockName || "the default block"}.`, node, this.template]);
324370
} else {
325-
errors.push([`No state(s) found named ${stateName} for ${container.asSource()} in ${blockName || "the default block"}.`, node, this.template]);
371+
errors.push([`No state(s) found named ${stateName} for ${container.asSource()} in ${blockName === "block" && "the default block" || blockName}.`, node, this.template]);
326372
}
327373
}
328374
}
@@ -341,6 +387,9 @@ function isConcatStatement(value: AST.Node | undefined): value is AST.ConcatStat
341387
function isTextNode(value: AST.Node | undefined): value is AST.TextNode {
342388
return !!value && value.type === "TextNode";
343389
}
390+
function isBooleanLiteral(value: AST.Node | undefined): value is AST.BooleanLiteral {
391+
return !!value && value.type === "BooleanLiteral";
392+
}
344393
function isMustacheStatement(value: AST.Node | undefined): value is AST.MustacheStatement {
345394
return !!value && value.type === "MustacheStatement";
346395
}

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

+2-4
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import { getEmberBuiltInStates, isEmberBuiltIn } from "./EmberBuiltins";
2121
import { CONCAT_HELPER_NAME } from "./helpers";
2222
import { ResolvedFile, TEMPLATE_TYPE } from "./Template";
2323

24-
// TODO: The state namespace should come from a config option.
25-
const STYLE_ATTR = /^(class$|state:)/;
2624
const DEBUG = debugGenerator("css-blocks:glimmer:rewriter");
2725

2826
export type GlimmerStyleMapping = StyleMapping<TEMPLATE_TYPE>;
@@ -132,7 +130,7 @@ export class GlimmerRewriter implements ASTPluginWithDeps {
132130
const attrMap = this.elementAnalyzer.analyzeForRewrite(node, atRootElement);
133131

134132
// Remove all the source attributes for styles.
135-
node.hash.pairs = node.hash.pairs.filter(a => !STYLE_ATTR.test(a.key)).filter(a => !attrToStateMap[a.key]);
133+
node.hash.pairs = node.hash.pairs.filter(a => this.elementAnalyzer.isAttributeAnalyzed(a.key)[0] === null).filter(a => !attrToStateMap[a.key]);
136134

137135
for (let attr of Object.keys(attrMap)) {
138136
let element = attrMap[attr];
@@ -170,7 +168,7 @@ export class GlimmerRewriter implements ASTPluginWithDeps {
170168
let attrMap = this.elementAnalyzer.analyzeForRewrite(node, atRootElement);
171169

172170
// Remove all the source attributes for styles.
173-
node.attributes = node.attributes.filter(a => !STYLE_ATTR.test(a.name));
171+
node.attributes = node.attributes.filter(a => this.elementAnalyzer.isAttributeAnalyzed(a.name)[0] === null);
174172

175173
for (let attr of Object.keys(attrMap)) {
176174
let element = attrMap[attr];
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
<div state:loading={{isLoading}}>
2-
<aside class="sidebar grid.one-fifth" state:collapsed state:grid.gutter="right">
1+
<div block:scope block:loading={{isLoading}}>
2+
<aside block:class="sidebar" grid:class="one-fifth" block:collapsed grid:gutter="right">
33
</aside>
4-
<article class="{{style-if isRecommended 'recommended' 'main'}} grid.four-fifths">
4+
<article block:class="{{style-if isRecommended 'recommended' 'main'}}" grid:class="four-fifths">
55
</article>
66
</div>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div state:is-loading>
1+
<div block:scope block:is-loading>
22
<page-banner />
3-
<text-editor disabled class="editor" state:disabled/>
3+
<text-editor disabled block:class="editor" block:disabled/>
44
</div>

packages/@css-blocks/glimmer/test/fixtures/styled-app/src/ui/components/with-dynamic-classes/stylesheet.css

+4
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@
1313
border-width: 3px;
1414
}
1515

16+
.planet {
17+
border: 3px groove gray;
18+
}
19+
1620
@export (h, t);
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div>
2-
<h1 class="h">Hello, <span class="{{style-if isWorld 'world'}} h.emphasis t.underline" state:thick={{eq isThick 1}} state:h.style={{textStyle}}>World</span>!</h1>
3-
<div class={{style-if isWorld 'world' 'h.emphasis'}}>World</div>
4-
<div class={{style-unless isWorld 'world' 'h.emphasis'}}>World</div>
5-
<div class={{style-unless isWorld 'world'}}>World</div>
2+
<h1 h:scope>Hello, <span block:class={{style-if isWorld 'world'}} h:class="emphasis" t:class="underline" block:thick={{eq isThick 1}} h:style={{textStyle}}>World</span>!</h1>
3+
<div block:class={{style-if isWorld 'world' 'planet'}}>World</div>
4+
<div block:class={{style-unless isWorld 'world' 'planet'}}>World</div>
5+
<div block:class={{style-unless isWorld 'world'}}>World</div>
66
</div>
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<div>
2-
<h1 class="h">Hello, <span class="world h.emphasis" state:thick={{isThick}} state:h.style={{textStyle}}>World</span>!</h1>
2+
<h1 h:scope>Hello, <span block:class="world" h:class="emphasis" block:thick={{isThick}} h:style={{textStyle}}>World</span>!</h1>
33
</div>
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
<div>
2-
{{link-to "Inline Form" "inline-form" class="link-1"}}
3-
{{#link-to "block-form" class="link-1 util.util"}}Block Form{{/link-to}}
2+
{{link-to "Inline Form" "inline-form" block:class="link-1"}}
3+
{{#link-to "block-form" block:class="link-1" util:class="util"}}Block Form{{/link-to}}
44

5-
{{link-to "Inline Form" "inline-form-active" class="link-2"}}
6-
{{#link-to "block-form-active" class="link-2"}}Block Form{{/link-to}}
5+
{{link-to "Inline Form" "inline-form-active" block:class="link-2"}}
6+
{{#link-to "block-form-active" block:class="link-2"}}Block Form{{/link-to}}
77

8-
{{link-to "Dynamic Inline Form" "inline-form-active" class=(style-if foo "link-2") activeClass="whatever"}}
9-
{{#link-to "block-form-active" class=(style-if foo "link-2")}}Dynamic Block Form{{/link-to}}
8+
{{link-to "Dynamic Inline Form" "inline-form-active" block:class=(style-if foo "link-2") activeClass="whatever"}}
9+
{{#link-to "block-form-active" block:class=(style-if foo "link-2")}}Dynamic Block Form{{/link-to}}
1010

11-
{{link-to "Inline Form, Inherited State" "inline-form-active" class="link-3" activeClass="whatever"}}
12-
{{#link-to "block-form-active" class="link-3"}}Block Form, Inherited State{{/link-to}}
11+
{{link-to "Inline Form, Inherited State" "inline-form-active" block:class="link-3" activeClass="whatever"}}
12+
{{#link-to "block-form-active" block:class="link-3"}}Block Form, Inherited State{{/link-to}}
1313

14-
{{link-to "Inline Form, External State" "inline-form-active" class="external.link-3" activeClass="whatever"}}
15-
{{#link-to "block-form-active" class="external.link-3"}}Block Form, External State{{/link-to}}
14+
{{link-to "Inline Form, External State" "inline-form-active" external:class="link-3" activeClass="whatever"}}
15+
{{#link-to "block-form-active" external:class="link-3"}}Block Form, External State{{/link-to}}
1616

17-
{{link-to "Inline Form, All States" "inline-form-active" class="link-4" loadingClass="whatever"}}
18-
{{#link-to "block-form-active" class="link-4" disabledClass="whatever"}}Block Form, All States{{/link-to}}
17+
{{link-to "Inline Form, All States" "inline-form-active" block:class="link-4" loadingClass="whatever"}}
18+
{{#link-to "block-form-active" block:class="link-4" disabledClass="whatever"}}Block Form, All States{{/link-to}}
1919

2020
</div>
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<div>
2-
<h1 class="h">Hello, <span class="world h.emphasis" state:thick state:h.extra>World</span>!</h1>
2+
<h1 h:scope>Hello, <span block:class="world" h:class="emphasis" block:thick h:extra>World</span>!</h1>
33
</div>

0 commit comments

Comments
 (0)