Skip to content

Commit da10830

Browse files
authored
feat: In-Stylesheet Block Composition (linkedin#229)
* feat: Track composition metadata on BlockClass. * feat: Improved @block-debug output. * feat: Composition conflict validation. * feat: In-stylesheet composition template rewrites. * fix: Update tests to use new debug output. * fix: Fix overzealous error throwing in Glimmer analyzer. * docs: Update readme to reflect feature status.
1 parent 2f93f99 commit da10830

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2039
-308
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ CSS Blocks is under active development and there are a number of features that h
119119
|| `block-name: "custom-name";` | Provide custom Block names in `:scope` for a nicer debugging experience. |
120120
|| `implements: block-name;` | A Block can declare that it implements one or more other Block's interfaces in its `:scope` selector and the compiler will ensure that all of those states and classes are styled locally. |
121121
|| `extends: block-name;` | A Block may specify it extends another Block in its `:scope` selector to inherit and extend all the class and state implementations therein. |
122-
| 🖌 | `apply: "block.path";` | Mixin-Style class and state composition. Apply other Blocks' Styles to one of yours. |
122+
| | `composes: "block.path";` | Mixin-Style class and state composition. Apply other Blocks' Styles to one of yours. |
123123
| **Functions** ||
124124
|| `resolve("block.path");` | Provide an explicit resolution for a given property against another Block. |
125125
|| `constrain(val1, val2 ... valN);` | Constrain this property to a list of specific values that may be set when this Block is extended. |

packages/@css-blocks/core/src/Analyzer/ElementAnalysis.ts

Lines changed: 204 additions & 59 deletions
Large diffs are not rendered by default.

packages/@css-blocks/core/src/Analyzer/validations/attribute-group-validator.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,26 @@ function ensureUniqueAttributeGroup(discovered: Set<Attribute>, group: Attribute
2828
export const attributeGroupValidator: Validator = (analysis, _templateAnalysis, err) => {
2929
let discovered: Set<Attribute> = new Set();
3030
for (let o of analysis.static) {
31-
if (isAttrValue(o)) {
31+
if (isAttrValue(o) && !analysis.isFromComposition(o)) {
3232
ensureUniqueAttributeGroup(discovered, o.attribute, err, true);
3333
}
3434
}
3535
for (let stat of analysis.dynamicAttributes) {
3636
if (isBooleanAttr(stat)) {
37-
ensureUniqueAttributeGroup(discovered, stat.value.attribute, err, true);
37+
for (let val of stat.value) {
38+
if (isAttrValue(val) && !analysis.isFromComposition(val)) {
39+
ensureUniqueAttributeGroup(discovered, val.attribute, err, true);
40+
}
41+
}
3842
}
3943
if (isAttrGroup(stat)) {
4044
let tmp: Set<Attribute> = new Set();
4145
for (let key of Object.keys(stat.group)) {
4246
let attr = stat.group[key];
43-
let values = ensureUniqueAttributeGroup(discovered, attr.attribute, err, false);
44-
values.forEach((o) => tmp.add(o));
47+
if (isAttrValue(attr) && !analysis.isFromComposition(attr)) {
48+
let values = ensureUniqueAttributeGroup(discovered, attr.attribute, err, false);
49+
values.forEach((o) => tmp.add(o));
50+
}
4551
}
4652
unionInto(discovered, tmp);
4753
}

packages/@css-blocks/core/src/Analyzer/validations/attribute-parent-validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Validator } from "./Validator";
88

99
export const attributeParentValidator: Validator = (analysis, _templateAnalysis, err) => {
1010
for (let attr of analysis.attributesFound()) {
11-
if (!analysis.hasClass(attr.blockClass)) {
11+
if (!analysis.hasClass(attr.blockClass) && !analysis.isFromComposition(attr.blockClass)) {
1212
err(`Cannot use state "${attr.asSource()}" without parent ` +
1313
`${ attr.blockClass.isRoot ? "block" : "class" } also applied or implied by another style.`);
1414
}

packages/@css-blocks/core/src/Analyzer/validations/class-pairs-validator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { ErrorCallback, Validator } from "./Validator";
99
*/
1010

1111
export const classPairsValidator: Validator = (analysis, _templateAnalysis, err) => {
12-
// TODO: this doesn't work for dynamic classes
1312
let classPerBlock: Map<Block, BlockClass> = new Map();
1413
for (let container of analysis.classesFound(false)) {
1514
if (isBlockClass(container)) {
15+
if (analysis.isFromComposition(container)) { continue; }
1616
for (let block of checkExisting(classPerBlock, classPerBlock, container, err)) {
1717
classPerBlock.set(block, container);
1818
}
@@ -22,6 +22,7 @@ export const classPairsValidator: Validator = (analysis, _templateAnalysis, err)
2222
let trueBlocks = new Map<Block, BlockClass>();
2323
if (isTrueCondition(dyn)) {
2424
for (let container of dyn.whenTrue) {
25+
if (analysis.isFromComposition(container)) { continue; }
2526
if (isBlockClass(container)) {
2627
let blocks = checkExisting(classPerBlock, trueBlocks, container, err);
2728
for (let block of blocks) { trueBlocks.set(block, container); }
@@ -32,6 +33,7 @@ export const classPairsValidator: Validator = (analysis, _templateAnalysis, err)
3233
if (isFalseCondition(dyn)) {
3334
for (let container of dyn.whenFalse) {
3435
if (isBlockClass(container)) {
36+
if (analysis.isFromComposition(container)) { continue; }
3537
let blocks = checkExisting(classPerBlock, falseBlocks, container, err);
3638
for (let block of blocks) { trueBlocks.set(block, container); }
3739
}

packages/@css-blocks/core/src/Analyzer/validations/property-conflict-validator.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { MultiMap, TwoKeyMultiMap, objectValues } from "@opticss/util";
1+
import { MultiMap, TwoKeyMultiMap, objectValues, whatever } from "@opticss/util";
22
import * as propParser from "css-property-parser";
33
import { postcss } from "opticss";
44

5-
import { Ruleset, Style } from "../../BlockTree";
5+
import { AttrValue, BlockClass, Ruleset, Style, isBlockClass } from "../../BlockTree";
66
import {
7+
ElementAnalysis,
78
isAttrGroup,
89
isBooleanAttr,
910
isFalseCondition,
@@ -122,6 +123,21 @@ function printRulesetConflict(prop: string, rule: Ruleset) {
122123
return out.join("\n");
123124
}
124125

126+
function inStylesheetComposition(
127+
blockClass: BlockClass,
128+
analysis: ElementAnalysis<whatever, whatever, whatever>,
129+
conflicts: ConflictMap,
130+
allConditions: PropMap,
131+
) {
132+
composed: for (let composed of blockClass.composedStyles()) {
133+
for (let condition of composed.conditions) {
134+
if (!analysis.hasAttribute(condition)) { break composed; }
135+
}
136+
evaluate(composed.style, allConditions, conflicts);
137+
add(allConditions, composed.style);
138+
}
139+
}
140+
125141
/**
126142
* Prevent conflicting styles from being applied to the same element without an explicit resolution.
127143
* @param correlations The correlations object for a given element.
@@ -139,6 +155,10 @@ export const propertyConflictValidator: Validator = (elAnalysis, _templateAnalys
139155
elAnalysis.static.forEach((obj) => {
140156
evaluate(obj, allConditions, conflicts);
141157
add(allConditions, obj);
158+
159+
// TODO: When we unify Element Analysis and Stylesheet Composition concepts, this check
160+
// can happen in another location during the BlockParse instead of Template Validation.
161+
if (isBlockClass(obj)) { inStylesheetComposition(obj, elAnalysis, conflicts, allConditions); }
142162
});
143163

144164
// For each dynamic class, test it against the static classes,
@@ -182,8 +202,10 @@ export const propertyConflictValidator: Validator = (elAnalysis, _templateAnalys
182202
}
183203

184204
else if (isBooleanAttr(condition)) {
185-
evaluate(condition.value, allConditions, conflicts);
186-
add(allConditions, condition.value);
205+
for (let val of condition.value) {
206+
evaluate(val as AttrValue, allConditions, conflicts);
207+
add(allConditions, val as AttrValue);
208+
}
187209
}
188210
});
189211

@@ -194,7 +216,7 @@ export const propertyConflictValidator: Validator = (elAnalysis, _templateAnalys
194216

195217
// For every set of conflicting properties, throw the error.
196218
if (conflicts.size) {
197-
let msg = "The following property conflicts must be resolved for these co-located Styles:";
219+
let msg = "The following property conflicts must be resolved for these composed Styles:";
198220
let details = "\n";
199221
for (let [prop, matches] of conflicts.entries()) {
200222
if (!prop || !matches.length) { return; }

packages/@css-blocks/core/src/Analyzer/validations/root-class-validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const rootClassValidator: Validator = (analysis, templateAnalysis, err) =
1414
foundClass = foundClass || !container.isRoot;
1515
}
1616
if (foundRoot && foundClass) {
17-
err(`Cannot put block classes on the block's root element`);
17+
err(`Cannot put Block classes on the Block's root element.`);
1818
}
1919
}
2020
};

packages/@css-blocks/core/src/BlockCompiler/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ export class BlockCompiler {
7575
root.walkAtRules(BLOCK_DEBUG, (atRule) => {
7676
let {block: ref, channel} = parseBlockDebug(atRule, sourceFile, block);
7777
if (channel === "comment") {
78-
let debugStr = ref.debug(this.config);
79-
atRule.replaceWith(this.postcss.comment({text: debugStr.join("\n ")}));
78+
let text = `${ref.debug(this.config).join("\n * ")}\n`;
79+
atRule.replaceWith(this.postcss.comment({ text }));
8080
} else {
8181
// stderr/stdout are emitted during parse.
8282
atRule.remove();

packages/@css-blocks/core/src/BlockParser/BlockParser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as errors from "../errors";
77
import { FileIdentifier } from "../importing";
88

99
import { assertForeignGlobalAttribute } from "./features/assert-foreign-global-attribute";
10+
import { composeBlock } from "./features/composes-block";
1011
import { constructBlock } from "./features/construct-block";
1112
import { disallowImportant } from "./features/disallow-important";
1213
import { discoverName } from "./features/discover-name";
@@ -98,6 +99,8 @@ export class BlockParser {
9899
// Validate that all required Styles are implemented.
99100
debug(` - Implement Block`);
100101
await implementBlock(root, block, debugIdent);
102+
// Register all block compositions.
103+
await composeBlock(root, block, debugIdent);
101104
// Log any debug statements discovered.
102105
debug(` - Process Debugs`);
103106
await processDebugStatements(root, block, debugIdent, this.config);

packages/@css-blocks/core/src/BlockParser/block-intermediates.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { assertNever, whatever } from "@opticss/util";
2-
import { postcssSelectorParser as selectorParser } from "opticss";
2+
import { CompoundSelector, postcssSelectorParser as selectorParser } from "opticss";
33

44
import { ATTR_PRESENT, AttrToken, ROOT_CLASS, STATE_NAMESPACE } from "../BlockSyntax";
5+
import { AttrValue, Block, BlockClass } from "../BlockTree";
56

67
export enum BlockType {
78
block = 1,
@@ -127,3 +128,44 @@ export const isClassNode = selectorParser.isClassName;
127128
export function isAttributeNode(node: selectorParser.Node): node is selectorParser.Attribute {
128129
return selectorParser.isAttribute(node) && node.namespace === STATE_NAMESPACE;
129130
}
131+
132+
/**
133+
* Describes all possible terminating styles in a CSS Blocks selector.
134+
*/
135+
export interface StyleTargets {
136+
blockAttrs: AttrValue[];
137+
blockClasses: BlockClass[];
138+
}
139+
140+
/**
141+
* Given a Block and ParsedSelector, return all terminating Style objects.
142+
* These may be either a single `BlockClass` or 1 to many `AttrValue`s.
143+
* @param block The Block to query against.
144+
* @param sel The ParsedSelector
145+
* @returns The array of discovered Style objects.
146+
*/
147+
export function getStyleTargets(block: Block, sel: CompoundSelector): StyleTargets {
148+
let blockAttrs: AttrValue[] = [];
149+
let blockClass: BlockClass | undefined = undefined;
150+
151+
for (let node of sel.nodes) {
152+
if (isRootNode(node)) {
153+
blockClass = block.rootClass;
154+
}
155+
else if (isClassNode(node)) {
156+
blockClass = block.ensureClass(node.value);
157+
}
158+
else if (isAttributeNode(node)) {
159+
// The fact that a base class exists for all state selectors is
160+
// validated in `assertBlockObject`. BlockClass may be undefined
161+
// here if parsing a global state.
162+
if (!blockClass) { continue; }
163+
blockAttrs.push(blockClass.ensureAttributeValue(toAttrToken(node)));
164+
}
165+
}
166+
167+
return {
168+
blockAttrs,
169+
blockClasses: blockClass ? [ blockClass ] : [],
170+
};
171+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { postcss } from "opticss";
2+
import { isRule } from "opticss/dist/src/util/cssIntrospection";
3+
4+
import { COMPOSES } from "../../BlockSyntax";
5+
import { Block } from "../../BlockTree";
6+
import * as errors from "../../errors";
7+
import { sourceLocation } from "../../SourceLocation";
8+
import { getStyleTargets } from "../block-intermediates";
9+
import { stripQuotes } from "../utils";
10+
11+
/**
12+
* For each `composes` property found in the passed ruleset, track the foreign
13+
* block. If block is not found, throw.
14+
* @param block Block object being processed
15+
* @param sourceFile Source file name, used for error output.
16+
* @param rule Ruleset to crawl
17+
*/
18+
export async function composeBlock(root: postcss.Root, block: Block, sourceFile: string) {
19+
root.walkDecls(COMPOSES, (decl) => {
20+
if (!isRule(decl.parent)) { throw new errors.InvalidBlockSyntax(`The "composes" property may only be used in a rule set.`, sourceLocation(sourceFile, decl)); }
21+
let rule = decl.parent;
22+
23+
// TODO: Move to Block Syntax as parseBlockRefList().
24+
let refNames = decl.value.split(/,\s*/).map(stripQuotes);
25+
for (let refName of refNames) {
26+
let refStyle = block.lookup(refName);
27+
if (!refStyle) {
28+
throw new errors.InvalidBlockSyntax(`No style "${refName}" found.`, sourceLocation(sourceFile, decl));
29+
}
30+
if (refStyle.block === block) {
31+
throw new errors.InvalidBlockSyntax(`Styles from the same Block may not be composed together.`, sourceLocation(sourceFile, decl));
32+
}
33+
34+
const parsedSel = block.getParsedSelectors(rule);
35+
for (let sel of parsedSel) {
36+
if (sel.selector.next) {
37+
throw new errors.InvalidBlockSyntax(`Style composition is not allowed in rule sets with a scope selector.`, sourceLocation(sourceFile, decl));
38+
}
39+
let foundStyles = getStyleTargets(block, sel.selector);
40+
for (let blockClass of foundStyles.blockClasses) {
41+
blockClass.addComposedStyle(refStyle, foundStyles.blockAttrs);
42+
}
43+
}
44+
}
45+
decl.remove();
46+
});
47+
}

packages/@css-blocks/core/src/BlockParser/features/construct-block.ts

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { CompoundSelector, ParsedSelector, postcss, postcssSelectorParser as selectorParser } from "opticss";
22

3-
import { Block, BlockClass, Style } from "../../BlockTree";
3+
import { Block, Style } from "../../BlockTree";
44
import * as errors from "../../errors";
55
import { selectorSourceLocation as loc, sourceLocation } from "../../SourceLocation";
66
import {
77
BlockType,
88
NodeAndType,
99
blockTypeName,
10+
getStyleTargets,
1011
isAttributeNode,
1112
isClassLevelObject,
1213
isClassNode,
1314
isExternalBlock,
1415
isRootLevelObject,
1516
isRootNode,
16-
toAttrToken,
1717
} from "../block-intermediates";
1818

1919
const SIBLING_COMBINATORS = new Set(["+", "~"]);
@@ -71,8 +71,7 @@ export async function constructBlock(root: postcss.Root, block: Block, file: str
7171
while (sel) {
7272

7373
let isKey = (keySel === sel);
74-
let blockClass: BlockClass | undefined = undefined;
75-
let foundStyles: Style[] = [];
74+
let foundStyles = getStyleTargets(block, sel);
7675

7776
// If this is an external Style, move on. These are validated
7877
// in `assert-foreign-global-attribute`.
@@ -82,26 +81,13 @@ export async function constructBlock(root: postcss.Root, block: Block, file: str
8281
continue;
8382
}
8483

85-
for (let node of sel.nodes) {
86-
if (isRootNode(node)) {
87-
blockClass = block.rootClass;
88-
}
89-
else if (isClassNode(node)) {
90-
blockClass = block.ensureClass(node.value);
91-
}
92-
else if (isAttributeNode(node)) {
93-
// The fact that a base class exists for all state selectors is
94-
// validated in `assertBlockObject`.
95-
foundStyles.push(blockClass!.ensureAttributeValue(toAttrToken(node)));
96-
}
97-
}
98-
99-
// If we haven't found any terminating states, we're targeting the discovered Block class.
100-
if (blockClass && !foundStyles.length) { foundStyles.push(blockClass); }
101-
10284
// If this is the key selector, save this ruleset on the created style.
10385
if (isKey) {
104-
foundStyles.map(s => styleRuleTuples.add([s, rule]));
86+
if (foundStyles.blockAttrs.length) {
87+
foundStyles.blockAttrs.map(s => styleRuleTuples.add([s, rule]));
88+
} else {
89+
foundStyles.blockClasses.map(s => styleRuleTuples.add([s, rule]));
90+
}
10591
}
10692

10793
sel = sel.next && sel.next.selector;
@@ -110,7 +96,7 @@ export async function constructBlock(root: postcss.Root, block: Block, file: str
11096
});
11197

11298
// To allow self-referential block lookup when constructing ruleset concerns,
113-
// we need to run `addRuleset()` only *after* all Style have been created.
99+
// we need to run `addRuleset()` only *after* all Styles have been created.
114100
for (let [style, rule] of styleRuleTuples) {
115101
style.rulesets.addRuleset(file, rule);
116102
}

packages/@css-blocks/core/src/BlockParser/features/export-blocks.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,10 @@ import * as errors from "../../errors";
66
import { sourceLocation } from "../../SourceLocation";
77

88
import { BlockFactory } from "../index";
9-
import { parseBlockNames } from "../utils/blockNamesParser";
9+
import { parseBlockNames, stripQuotes } from "../utils";
1010

1111
const FROM_EXPR = /\s+from\s+/;
1212

13-
/**
14-
* Strip matching quotes from the beginning and end of a string
15-
* @param str String to strip quotes from
16-
* @return Result
17-
*/
18-
function stripQuotes(str: string): string {
19-
return str.replace(/^(["'])(.+)\1$/, "$2");
20-
}
21-
2213
/**
2314
* Resolve all block references for a given block.
2415
* @param block Block to resolve references for
@@ -69,7 +60,6 @@ export async function exportBlocks(block: Block, factory: BlockFactory, file: st
6960
);
7061
}
7162
let localName = blockNames[remoteName];
72-
console.log(remoteName, localName, block.identifier);
7363
if (!CLASS_NAME_IDENT.test(localName)) {
7464
throw new errors.InvalidBlockSyntax(
7565
`Illegal block name in export. "${localName}" is not a legal CSS identifier.`,

0 commit comments

Comments
 (0)