Skip to content

Commit

Permalink
feat: support CSS nesting in CSS mixin (#2855)
Browse files Browse the repository at this point in the history
* refactor: mark mixin anchor with unique selector

* refactor: transform new mixin anchor

* refactor: infer mixin marker in transformation

* test: add skipped test to check mixin of CSS nesting

* refactor: move asserters to core-test-kit

* fix: handle nested when merging CSS fragment

* feat: support nesting in CSS mixin

* refactor: change marker to attribute

* refactor: moved default arg to function signature

* refactor: removed unnecessary spread
  • Loading branch information
idoros authored Apr 23, 2023
1 parent 03f368f commit b2063ee
Show file tree
Hide file tree
Showing 12 changed files with 322 additions and 79 deletions.
1 change: 1 addition & 0 deletions packages/core-test-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export { testStylableCore } from './test-stylable-core';
export { deindent } from './deindent';
export { MinimalDocument, MinimalElement } from './minimal-dom';
export { createTempDirectorySync, copyDirectory } from './native-temp-dir';
export { assertAtRule, assertComment, assertDecl, assertRule } from './postcss-node-asserts';
26 changes: 26 additions & 0 deletions packages/core-test-kit/src/postcss-node-asserts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type * as postcss from 'postcss';

export function assertRule(node: any, msg?: string): postcss.Rule {
if (node?.type !== 'rule') {
throw new Error('expected postcss rule' + (msg ? ` (${msg})` : ''));
}
return node;
}
export function assertAtRule(node: any, msg?: string): postcss.Rule {
if (node?.type !== 'atrule') {
throw new Error('expected postcss at-rule' + (msg ? ` (${msg})` : ''));
}
return node;
}
export function assertDecl(node: any, msg?: string): postcss.Declaration {
if (node?.type !== 'decl') {
throw new Error('expected postcss declaration' + (msg ? ` (${msg})` : ''));
}
return node;
}
export function assertComment(node: any, msg?: string): postcss.Comment {
if (node?.type !== 'comment') {
throw new Error('expected comment node' + (msg ? ` (${msg})` : ''));
}
return node;
}
31 changes: 27 additions & 4 deletions packages/core/src/features/st-mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as STCustomSelector from './st-custom-selector';
import * as STVar from './st-var';
import type { ElementSymbol } from './css-type';
import type { ClassSymbol } from './css-class';
import { createSubsetAst } from '../helpers/rule';
import { createSubsetAst, isStMixinMarker } from '../helpers/rule';
import { scopeNestedSelector } from '../helpers/selector';
import { mixinHelperDiagnostics, parseStMixin, parseStPartialMixin } from '../helpers/mixin';
import { resolveArgumentsValue } from '../functions';
Expand Down Expand Up @@ -110,6 +110,17 @@ export const diagnostics = {
// HOOKS

export const hooks = createFeature({
transformSelectorNode({ selectorContext, node }) {
const isMarker = isStMixinMarker(node);
if (isMarker) {
selectorContext.setNextSelectorScope(
selectorContext.inferredSelectorMixin,
node,
node.value
);
}
return isMarker;
},
transformLastPass({ context, ast, transformer, cssVarsMapping, path }) {
ast.walkRules((rule) => appendMixins(context, transformer, rule, cssVarsMapping, path));
},
Expand Down Expand Up @@ -422,7 +433,7 @@ function handleJSMixin(
meta.source
);

mergeRules(mixinRoot, config.rule, mixDef.data.originDecl, context.diagnostics);
mergeRules(mixinRoot, config.rule, mixDef.data.originDecl, context.diagnostics, true);
}

function handleCSSMixin(
Expand Down Expand Up @@ -498,11 +509,23 @@ function handleCSSMixin(
}

if (roots.length === 1) {
mergeRules(roots[0], config.rule, mixDef.data.originDecl, config.transformer.diagnostics);
mergeRules(
roots[0],
config.rule,
mixDef.data.originDecl,
config.transformer.diagnostics,
false
);
} else if (roots.length > 1) {
const mixinRoot = postcss.root();
roots.forEach((root) => mixinRoot.prepend(...root.nodes));
mergeRules(mixinRoot, config.rule, mixDef.data.originDecl, config.transformer.diagnostics);
mergeRules(
mixinRoot,
config.rule,
mixDef.data.originDecl,
config.transformer.diagnostics,
false
);
}
}

Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/helpers/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ImmutableSelectorNode,
groupCompoundSelectors,
SelectorList,
SelectorNode,
} from '@tokey/css-selector-parser';
import * as postcss from 'postcss';
import { transformInlineCustomSelectors } from './custom-selector';
Expand All @@ -34,8 +35,8 @@ export function isInConditionalGroup(node: postcss.Rule | postcss.AtRule, includ
);
}

export function createSubsetAst<T extends postcss.Root | postcss.AtRule>(
root: postcss.Root | postcss.AtRule,
export function createSubsetAst<T extends postcss.Root | postcss.AtRule | postcss.Rule>(
root: postcss.Root | postcss.AtRule | postcss.Rule,
selectorPrefix: string,
mixinTarget?: T,
isRoot = false,
Expand All @@ -48,7 +49,12 @@ export function createSubsetAst<T extends postcss.Root | postcss.AtRule>(
const containsPrefix = containsMatchInFirstChunk.bind(null, prefixType);
const mixinRoot = mixinTarget ? mixinTarget : postcss.root();
root.nodes.forEach((node) => {
if (node.type === `rule` && (node.selector === ':vars' || node.selector === ':import')) {
if (node.type === 'decl') {
mixinTarget?.append(node.clone());
} else if (
node.type === `rule` &&
(node.selector === ':vars' || node.selector === ':import')
) {
// nodes that don't mix
return;
} else if (node.type === `rule`) {
Expand All @@ -70,12 +76,20 @@ export function createSubsetAst<T extends postcss.Root | postcss.AtRule>(
if (!isRoot) {
selectorNode = fixChunkOrdering(selectorNode, prefixType);
}
replaceTargetWithNesting(selectorNode, prefixType);
replaceTargetWithMixinAnchor(selectorNode, prefixType);
return selectorNode;
})
);

mixinRoot.append(node.clone({ selector }));
const clonedRule = createSubsetAst(
node,
selectorPrefix,
node.clone({ selector, nodes: [] }),
isRoot,
getCustomSelector,
true /*isNestedInMixin*/
);
mixinRoot.append(clonedRule);
}
} else if (node.type === `atrule`) {
if (
Expand All @@ -99,7 +113,7 @@ export function createSubsetAst<T extends postcss.Root | postcss.AtRule>(
if (!isRoot) {
selectorNode = fixChunkOrdering(selectorNode, prefixType);
}
replaceTargetWithNesting(selectorNode, prefixType);
replaceTargetWithMixinAnchor(selectorNode, prefixType);
return selectorNode;
})
);
Expand Down Expand Up @@ -130,13 +144,16 @@ export function createSubsetAst<T extends postcss.Root | postcss.AtRule>(
return mixinRoot as T;
}

function replaceTargetWithNesting(selectorNode: Selector, prefixType: ImmutableSelectorNode) {
export const stMixinMarker = 'st-mixin-marker';
export const isStMixinMarker = (node: SelectorNode) =>
node.type === 'attribute' && node.value === stMixinMarker;
function replaceTargetWithMixinAnchor(selectorNode: Selector, prefixType: ImmutableSelectorNode) {
walkSelector(selectorNode, (node) => {
if (matchTypeAndValue(node, prefixType)) {
convertToSelector(node).nodes = [
{
type: `nesting`,
value: `&`,
type: `attribute`,
value: stMixinMarker,
start: node.start,
end: node.end,
},
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/helpers/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function isCompRoot(name: string) {
return name.charAt(0).match(/[A-Z]/);
}

const isNestedNode = (node: SelectorNode) => node.type === 'nesting';
/**
* combine 2 selector lists.
* - add each scoping selector at the begging of each nested selector
Expand All @@ -175,7 +176,8 @@ export function isCompRoot(name: string) {
export function scopeNestedSelector(
scopeSelectorAst: ImmutableSelectorList,
nestedSelectorAst: ImmutableSelectorList,
rootScopeLevel = false
rootScopeLevel = false,
isAnchor: (node: SelectorNode) => boolean = isNestedNode
): { selector: string; ast: SelectorList } {
const resultSelectors: SelectorList = [];
nestedSelectorAst.forEach((targetAst) => {
Expand Down Expand Up @@ -203,7 +205,7 @@ export function scopeNestedSelector(
: false;
let nestedMixRoot = false;
walkSelector(outputAst, (node, i, nodes) => {
if (node.type === 'nesting') {
if (isAnchor(node)) {
nestedMixRoot = true;
nodes.splice(i, 1, {
type: `selector`,
Expand Down
22 changes: 17 additions & 5 deletions packages/core/src/stylable-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export class StylableTransformer {
stVarOverride: Record<string, string> = this.defaultStVarOverride,
path: string[] = [],
mixinTransform = false,
inferredNestSelector?: InferredSelector
inferredSelectorMixin?: InferredSelector
) {
if (meta.type !== 'stylable') {
return;
Expand Down Expand Up @@ -340,8 +340,8 @@ export class StylableTransformer {
meta,
node.selector,
node,
(currentParent && this.containerInferredSelectorMap.get(currentParent)) ||
inferredNestSelector
currentParent && this.containerInferredSelectorMap.get(currentParent),
inferredSelectorMixin
);
// save results
this.containerInferredSelectorMap.set(node, inferredSelector);
Expand Down Expand Up @@ -413,6 +413,7 @@ export class StylableTransformer {
selector: string,
selectorNode?: postcss.Rule | postcss.AtRule,
inferredNestSelector?: InferredSelector,
inferredMixinSelector?: InferredSelector,
unwrapGlobals = false
): {
selector: string;
Expand All @@ -425,7 +426,8 @@ export class StylableTransformer {
parseSelectorWithCache(selector, { clone: true }),
selectorNode || postcss.rule({ selector }),
selector,
inferredNestSelector
inferredNestSelector,
inferredMixinSelector
);
const targetSelectorAst = this.scopeSelectorAst(context);
if (unwrapGlobals) {
Expand All @@ -443,7 +445,8 @@ export class StylableTransformer {
selectorAst: SelectorList,
selectorNode: postcss.Rule | postcss.AtRule,
selectorStr?: string,
selectorNest?: InferredSelector
selectorNest?: InferredSelector,
selectorMixin?: InferredSelector
) {
return new ScopeContext(
meta,
Expand All @@ -453,6 +456,7 @@ export class StylableTransformer {
this.scopeSelectorAst.bind(this),
this,
selectorNest,
selectorMixin,
undefined,
selectorStr
);
Expand Down Expand Up @@ -595,6 +599,12 @@ export class StylableTransformer {
}
} else if (node.type === `nesting`) {
context.setNextSelectorScope(context.inferredSelectorNest, node, node.value);
} else if (node.type === 'attribute') {
STMixin.hooks.transformSelectorNode({
context: transformerContext,
selectorContext: context,
node,
});
}
}
}
Expand Down Expand Up @@ -990,6 +1000,7 @@ export class ScopeContext {
public scopeSelectorAst: StylableTransformer['scopeSelectorAst'],
private transformer: StylableTransformer,
inferredSelectorNest?: InferredSelector,
public inferredSelectorMixin?: InferredSelector,
inferredSelectorContext?: InferredSelector,
selectorStr?: string
) {
Expand Down Expand Up @@ -1072,6 +1083,7 @@ export class ScopeContext {
this.scopeSelectorAst,
this.transformer,
this.inferredSelectorNest,
this.inferredSelectorMixin,
selectorContext || this.inferredSelectorContext
);
ctx.transform = this.transform;
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/stylable-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isAbsolute } from 'path';
import type * as postcss from 'postcss';
import { createDiagnosticReporter, Diagnostics } from './diagnostics';
import type { ImportSymbol, StylableSymbol } from './features';
import { isChildOfAtRule } from './helpers/rule';
import { isChildOfAtRule, stMixinMarker, isStMixinMarker } from './helpers/rule';
import { scopeNestedSelector, parseSelectorWithCache } from './helpers/selector';

export function isValidDeclaration(decl: postcss.Declaration) {
Expand All @@ -22,29 +22,36 @@ export function mergeRules(
mixinAst: postcss.Root,
rule: postcss.Rule,
mixinDecl: postcss.Declaration,
report?: Diagnostics
report: Diagnostics,
useNestingAsAnchor: boolean
) {
let mixinRoot: postcss.Rule | null | 'NoRoot' = null;
const nestedInKeyframes = isChildOfAtRule(rule, `keyframes`);
const anchorSelector = useNestingAsAnchor ? '&' : '[' + stMixinMarker + ']';
const anchorNodeCheck = useNestingAsAnchor ? undefined : isStMixinMarker;
mixinAst.walkRules((mixinRule: postcss.Rule) => {
if (isChildOfAtRule(mixinRule, 'keyframes')) {
return;
}
if (mixinRule.selector === '&' && !mixinRoot) {
if (mixinRule.selector === anchorSelector && !mixinRoot) {
if (mixinRule.parent === mixinAst) {
mixinRoot = mixinRule;
} else {
const { selector } = scopeNestedSelector(
parseSelectorWithCache(rule.selector),
parseSelectorWithCache(mixinRule.selector)
parseSelectorWithCache(mixinRule.selector),
false,
anchorNodeCheck
);
mixinRoot = 'NoRoot';
mixinRule.selector = selector;
}
} else {
const { selector } = scopeNestedSelector(
parseSelectorWithCache(rule.selector),
parseSelectorWithCache(mixinRule.selector)
parseSelectorWithCache(mixinRule.selector),
false,
anchorNodeCheck
);
mixinRule.selector = selector;
}
Expand All @@ -55,9 +62,9 @@ export function mergeRules(
// TODO: handle rules before and after decl on entry
mixinAst.nodes.slice().forEach((node) => {
if (node === mixinRoot) {
node.walkDecls((node) => {
rule.insertBefore(mixinDecl, node);
});
for (const nested of [...node.nodes]) {
rule.insertBefore(mixinDecl, nested);
}
} else if (node.type === 'decl') {
rule.insertBefore(mixinDecl, node);
} else if (node.type === 'rule' || node.type === 'atrule') {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/stylable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export class Stylable {
): { selector: string; resolved: ResolvedElement[][] } {
const meta = typeof pathOrMeta === `string` ? this.analyze(pathOrMeta) : pathOrMeta;
const transformer = this.createTransformer(options);
const r = transformer.scopeSelector(meta, selector, undefined, undefined, true);
const r = transformer.scopeSelector(meta, selector, undefined, undefined, undefined, true);
return {
selector: r.selector,
resolved: r.elements,
Expand Down
Loading

0 comments on commit b2063ee

Please sign in to comment.