import { plugableRecord } from '../helpers/plugable-record'; import { FeatureContext, createFeature } from './feature'; import type { StylableMeta } from '../stylable-meta'; import * as STSymbol from './st-symbol'; import * as STCustomSelector from './st-custom-selector'; import * as STCustomState from './st-custom-state'; import type { MappedStates } from './st-custom-state'; import * as CSSClass from './css-class'; import { warnOnce } from '../helpers/deprecation'; import postcss from 'postcss'; import { parseCSSValue, stringifyCSSValue, BaseAstNode } from '@tokey/css-value-parser'; import { parseSelectorWithCache, walkSelector } from '../helpers/selector'; import { ImmutableSelector, stringifySelectorAst, ImmutablePseudoClass, ImmutableSelectorList, } from '@tokey/css-selector-parser'; import { createDiagnosticReporter } from '../diagnostics'; import { getAlias } from '../stylable-utils'; import { findAnything, findFatArrow, findNextClassNode, findNextPseudoClassNode, findPseudoElementNode, isExactLiteral, } from '../helpers/css-value-seeker'; export const diagnostics = { GLOBAL_MAPPING_LIMITATION: createDiagnosticReporter( '21000', 'error', () => `Currently class mapping is limited to single global selector: :global()` ), UNSUPPORTED_TOP_DEF: createDiagnosticReporter( '21001', 'error', () => 'top level @st must start with a class' ), MISSING_EXTEND: createDiagnosticReporter( '21002', 'error', () => `missing required class reference to extend a class (e.g. ":is(.class-name)"` ), OVERRIDE_IMPORTED_CLASS: createDiagnosticReporter( '21003', 'error', () => `cannot override imported class definition` ), STATE_OUT_OF_CONTEXT: createDiagnosticReporter( '21004', 'error', () => 'pseudo-state definition must be directly nested in a `@st .class{}` definition' ), // 31005 - unused MISSING_MAPPED_SELECTOR: createDiagnosticReporter( '21006', 'error', () => `missing mapped selector after "=>"` ), MULTI_MAPPED_SELECTOR: createDiagnosticReporter( '21007', 'error', () => 'mapped selector accepts only a single selector.\nuse `:is()` or `:where()` to map multiple selectors)' ), ELEMENT_OUT_OF_CONTEXT: createDiagnosticReporter( '21008', 'error', () => 'pseudo-element definition must be directly nested in a `@st .class{}` definition' ), MISSING_MAPPING: createDiagnosticReporter( '21009', 'error', () => 'expected selector mapping (e.g. "=> ")' ), REDECLARE: createDiagnosticReporter( '21010', 'error', (type: string, src: string) => `redeclare ${type} definition: "${src}"` ), INVALID_ST_DEF: createDiagnosticReporter( '21011', 'error', (params: string) => `invalid @st "${params}" definition` ), MAPPING_UNSUPPORTED_NESTING: createDiagnosticReporter( '21012', 'error', () => 'mapped selector can only contain `&` as an initial selector' ), UNEXPECTED_EXTRA_VALUE: createDiagnosticReporter( '21013', 'error', (extraValue: string) => `found unexpected extra value definition: "${extraValue}"` ), CLASS_OUT_OF_CONTEXT: createDiagnosticReporter( '21014', 'error', () => 'class definition must be top level' ), }; export interface PartSymbol extends HasParts, STCustomState.HasStates { _kind: 'part'; name: string; id: string; mapTo: ImmutableSelectorList | CSSClass.ClassSymbol; } export interface HasParts { '-st-parts': Record; } export const experimentalMsg = '[experimental feature] stylable structure (@st): API might change!'; const dataKey = plugableRecord.key<{ isStructureMode: boolean; analyzedDefs: WeakMap; analyzedDefToPartSymbol: Map; declaredClasses: Set; }>('st-structure'); // HOOKS export const hooks = createFeature({ analyzeInit(context) { const { meta } = context; if (meta.type !== 'stylable') { return; } const stAtRule = meta.sourceAst.nodes.find((node) => isStAtRule(node)); if (stAtRule) { warnOnce(experimentalMsg); const metaAnalysis = plugableRecord.getUnsafe(context.meta.data, dataKey); metaAnalysis.isStructureMode = true; } else { // set implicit root for legacy mode (root with flat structure) meta.root = 'root'; const rootSymbol = CSSClass.addClass(context, 'root'); rootSymbol[`-st-root`] = true; } }, metaInit({ meta }) { plugableRecord.set(meta.data, dataKey, { // default to legacy flat mode isStructureMode: false, analyzedDefs: new WeakMap(), analyzedDefToPartSymbol: new Map(), declaredClasses: new Set(), }); }, analyzeAtRule({ context, atRule, analyzeRule }) { if (!isStAtRule(atRule) || context.meta.type !== 'stylable') { return; } const { analyzedDefToPartSymbol, declaredClasses } = plugableRecord.getUnsafe( context.meta.data, dataKey ); const analyzed = analyzeStAtRule(atRule, context); if (!analyzed) { // not valid } else if (analyzed.type === 'topLevelClass') { declaredClasses.add(analyzed.name); CSSClass.addClass(context, analyzed.name, atRule); CSSClass.disableDirectivesForClass(context, analyzed.name); // extend class if (analyzed.extendedClass) { const extendedSymbol = CSSClass.get(context.meta, analyzed.extendedClass) || CSSClass.addClass(context, analyzed.extendedClass); CSSClass.extendTypedRule( context, atRule, '.' + analyzed.name, '-st-extends', getAlias(extendedSymbol) || extendedSymbol ); } // class mapping if (analyzed.mappedSelector) { // ToDo: support non global mapping const globalNode = findGlobalPseudo(analyzed); if (globalNode && globalNode.nodes?.length === 1) { const mappedSelectorAst = globalNode.nodes[0]; // analyze mapped selector analyzeRule( postcss.rule({ selector: stringifySelectorAst(mappedSelectorAst), source: atRule.source, }), { isScoped: false, originalNode: atRule, } ); // register global mapping to class CSSClass.extendTypedRule( context, atRule, analyzed.name, '-st-global', mappedSelectorAst.nodes ); } } } else if (analyzed.type === 'part') { const parentSymbol = getPartParentSymbol(context, analyzed, analyzedDefToPartSymbol); if (!parentSymbol) { // unreachable: assuming analyzing @st definitions dfs - class/part must be defined context.diagnostics.report(diagnostics.ELEMENT_OUT_OF_CONTEXT(), { node: atRule, }); return; } const partName = analyzed.name; // check re-declare if (getPart(parentSymbol, partName)) { const srcWord = '::' + partName; context.diagnostics.report(diagnostics.REDECLARE('pseudo-element', srcWord), { node: atRule, word: srcWord, }); return; } // analyze mapped selector analyzeRule( postcss.rule({ selector: stringifySelectorAst(analyzed.mappedSelector), source: atRule.source, }), { isScoped: true, originalNode: atRule, } ); // register part mapping to parent definition const partSymbol = setPart(parentSymbol, getSymbolId(parentSymbol), partName, [ analyzed.mappedSelector, ]); analyzedDefToPartSymbol.set(analyzed, partSymbol); } else if (analyzed.type === 'state') { const parentSymbol = analyzed.parentAnalyze.type === 'topLevelClass' ? CSSClass.get(context.meta, analyzed.parentAnalyze.name) : analyzedDefToPartSymbol.get(analyzed.parentAnalyze); if (!parentSymbol) { // unreachable: assuming analyzing @st definitions dfs - class/part must be defined context.diagnostics.report(diagnostics.STATE_OUT_OF_CONTEXT(), { node: atRule, }); return; } const mappedStates = (parentSymbol['-st-states'] ||= {}); const stateName = analyzed.name; if (mappedStates[stateName]) { // first state definition wins const srcWord = ':' + stateName; context.diagnostics.report(diagnostics.REDECLARE('pseudo-state', srcWord), { node: atRule, word: srcWord, }); return; } mappedStates[stateName] = analyzed.stateDef; } }, analyzeDone({ meta }) { const { isStructureMode } = plugableRecord.getUnsafe(meta.data, dataKey); if (meta.type === 'stylable' && !isStructureMode) { // legacy flat mode: // classes and custom-selectors are registered as .root pseudo-elements const customSelectors = STCustomSelector.getCustomSelectors(meta); const classes = CSSClass.getAll(meta); const rootClass = classes['root']; const rootId = getSymbolId(rootClass); // custom-selector definition precedence over class definition for (const [partName, mapTo] of Object.entries(customSelectors)) { setPart(rootClass, rootId, partName, mapTo); } for (const [className, classSymbol] of Object.entries(classes)) { if (className === 'root' || customSelectors[className]) { continue; } setPart(rootClass, rootId, className, classSymbol); } } }, }); // API function isStAtRule(node: postcss.AnyNode): node is postcss.AtRule { return node?.type === 'atrule' && node.name === 'st'; } function getPartParentSymbol( context: FeatureContext, { parentAnalyze }: ParsedStPart, analyzedDefToPartSymbol: Map ) { return parentAnalyze.type === 'topLevelClass' ? CSSClass.get(context.meta, parentAnalyze.name) : analyzedDefToPartSymbol.get(parentAnalyze); } export function isStructureMode(meta: StylableMeta) { return plugableRecord.getUnsafe(meta.data, dataKey).isStructureMode; } export function createPartSymbol( input: Partial & Pick ): PartSymbol { const parts = input['-st-parts'] || {}; const states = input['-st-states'] || {}; return { ...input, _kind: 'part', '-st-parts': parts, '-st-states': states }; } export function setPart( symbol: HasParts, parentId: string, partName: string, mapTo: PartSymbol['mapTo'] ) { const partSymbol = createPartSymbol({ name: partName, id: parentId + '::' + partName, mapTo, }); symbol['-st-parts'][partName] = partSymbol; return partSymbol; } export function getParts(symbol: HasParts) { return symbol['-st-parts']; } export function getPart(symbol: HasParts, name: string): PartSymbol | undefined { return symbol['-st-parts'][name]; } export function getPartNames(symbol: HasParts) { return Object.keys(symbol['-st-parts']); } function getSymbolId(symbol: CSSClass.ClassSymbol | PartSymbol) { return symbol._kind === 'class' ? '.' + symbol.name : symbol.id; } type ParsedStClass = { type: 'topLevelClass'; params: BaseAstNode[]; match: boolean; ranges: Record<'class' | 'extend' | 'mapArrow' | 'mapTo' | 'leftoverValue', BaseAstNode[]>; name: string; extendedClass?: string; mappedSelector?: ImmutableSelector; }; interface ParsedStPart { type: 'part'; params: BaseAstNode[]; match: boolean; ranges: Record<'pseudoElement' | 'mapArrow' | 'mapTo' | 'leftoverValue', BaseAstNode[]>; name: string; parentAnalyze: ParsedStClass | ParsedStPart; mappedArrow: boolean; mappedSelector: ImmutableSelector; } interface ParsedStState { type: 'state'; params: BaseAstNode[]; match: boolean; ranges: Record<'leftoverValue', BaseAstNode[]>; name: string; parentAnalyze: ParsedStClass | ParsedStPart; stateDef: MappedStates[string]; } function isMatch(result: any): result is AnalyzedStDef { return result.match; } type AnalyzedStDef = ParsedStClass | ParsedStPart | ParsedStState; function analyzeStAtRule( atRule: postcss.AtRule, context: FeatureContext ): AnalyzedStDef | undefined { // cache const { analyzedDefs } = plugableRecord.getUnsafe(context.meta.data, dataKey); if (analyzedDefs.has(atRule)) { return analyzedDefs.get(atRule); } // parse const params = parseCSSValue(atRuleFullParams(atRule)); const def = params.length === 0 ? undefined : parseClassDefinition(atRule, params) || parsePseudoElementDefinition(context, atRule, params) || parseStateDefinition(context, atRule, params); if (!def) { if (atRule.parent?.type === 'root') { context.diagnostics.report(diagnostics.UNSUPPORTED_TOP_DEF(), { node: atRule, }); } else { context.diagnostics.report(diagnostics.INVALID_ST_DEF(atRule.params), { node: atRule, }); } return; } // validate switch (def.type) { case 'topLevelClass': { if (!validateTopLevelClass({ def, atRule, context })) { return; } break; } case 'part': { if (!validatePart({ def, atRule, context })) { return; } break; } case 'state': { if (!validateState({ def, atRule, context })) { return; } break; } } if (!isMatch(def)) { return; } analyzedDefs.set(atRule, def); return def; } function validateTopLevelClass({ def, atRule, context, }: { def: Partial; atRule: postcss.AtRule; context: FeatureContext; }) { const { declaredClasses } = plugableRecord.getUnsafe(context.meta.data, dataKey); if (!def.ranges || !def.params) { // should always be provided by parser return false; } if (!def.name) { // ToDo: fix type to have name required return false; } if (def.ranges.extend.length && !def.extendedClass) { context.diagnostics.report(diagnostics.MISSING_EXTEND(), { node: atRule, word: stringifyCSSValue(def.ranges.extend), }); } if (def.ranges.leftoverValue.find((node) => node.type !== 'comment' && node.type !== 'space')) { const unexpectedValue = stringifyCSSValue(def.ranges.leftoverValue).trim(); context.diagnostics.report(diagnostics.UNEXPECTED_EXTRA_VALUE(unexpectedValue), { node: atRule, word: unexpectedValue, }); return false; } if (atRule.parent?.type !== 'root') { context.diagnostics.report(diagnostics.CLASS_OUT_OF_CONTEXT(), { node: atRule, }); return false; } const existingSymbol = STSymbol.get(context.meta, def.name); if (existingSymbol?._kind === 'import') { context.diagnostics.report(diagnostics.OVERRIDE_IMPORTED_CLASS(), { node: atRule, }); return false; } if (declaredClasses.has(def.name)) { // ToDo: use st-symbol redeclare api; improve st-symbol/css-class "final" marking and diagnostics const srcWord = '.' + def.name; context.diagnostics.report(diagnostics.REDECLARE('class', srcWord), { node: atRule, word: srcWord, }); return false; } if (def.ranges.mapArrow.length) { if (!def.mappedSelector) { // report missing selector const arrowEnd = def.ranges.mapArrow[def.ranges.mapArrow.length - 1]; for (let i = def.params.length - 1; i >= 0; i--) { const node: BaseAstNode = def.params[i]; if (node === arrowEnd) { break; } else if (isExactLiteral(node, ',')) { context.diagnostics.report(diagnostics.MULTI_MAPPED_SELECTOR(), { node: atRule, }); return false; } } context.diagnostics.report(diagnostics.MISSING_MAPPED_SELECTOR(), { node: atRule, }); return false; } const globalNode = findGlobalPseudo(def, true); if (!globalNode || !globalNode.nodes || globalNode.nodes.length !== 1) { context.diagnostics.report(diagnostics.GLOBAL_MAPPING_LIMITATION(), { node: atRule, word: stringifySelectorAst(def.mappedSelector).trim(), }); return false; } } return true; } function validatePart({ def, atRule, context, }: { def: Partial; atRule: postcss.AtRule; context: FeatureContext; }) { if (!def.parentAnalyze) { context.diagnostics.report(diagnostics.ELEMENT_OUT_OF_CONTEXT(), { node: atRule, }); return false; } if (!def.mappedSelector) { if (!def.mappedArrow) { context.diagnostics.report(diagnostics.MISSING_MAPPING(), { node: atRule, }); return false; } // report missing selector const arrowEnd = def.ranges!.mapArrow[def.ranges!.mapArrow.length - 1]; for (let i = def.params!.length - 1; i >= 0; i--) { const node = def.params![i]; if (node === arrowEnd) { break; } else if (isExactLiteral(node, ',')) { context.diagnostics.report(diagnostics.MULTI_MAPPED_SELECTOR(), { node: atRule, }); return false; } } context.diagnostics.report(diagnostics.MISSING_MAPPED_SELECTOR(), { node: atRule, }); return false; } if (validateNestingInMapping(def.mappedSelector, context, atRule)) { return false; } return true; } function validateState({ def, atRule, context, }: { def: Partial; atRule: postcss.AtRule; context: FeatureContext; }) { if (!def.parentAnalyze) { context.diagnostics.report(diagnostics.STATE_OUT_OF_CONTEXT(), { node: atRule, }); return false; } const [amountToActualValue] = findAnything(def.ranges!.leftoverValue, 0); if (amountToActualValue) { const unexpectedValue = stringifyCSSValue(def.ranges!.leftoverValue).trim(); context.diagnostics.report(diagnostics.UNEXPECTED_EXTRA_VALUE(unexpectedValue), { node: atRule, word: unexpectedValue, }); return false; } return true; } function findGlobalPseudo(def: Partial, checkAfter = false) { if (!def.mappedSelector) { return; } let globalNode: ImmutablePseudoClass | undefined = undefined; let foundUnexpectedSelector = false; for (const node of def.mappedSelector.nodes) { if (node.type === 'pseudo_class' && node.value === 'global' && !globalNode) { globalNode = node; if (!checkAfter) { break; } } else if (node.type !== 'comment') { foundUnexpectedSelector = true; } } return foundUnexpectedSelector ? undefined : globalNode; } function parseStateDefinition( context: FeatureContext, atRule: postcss.AtRule, params: BaseAstNode[] ) { const result: Partial = { type: 'state', params, match: true, ranges: { leftoverValue: [] }, }; let index = 0; const { analyzedDefs } = plugableRecord.getUnsafe(context.meta.data, dataKey); // name const [amountToName, nameNode] = findNextPseudoClassNode(params, 0); if (nameNode) { result.name = nameNode.value; } else { // not a pseudo-state definition return; } index += amountToName; // parent const parentRule = atRule.parent; const parentAnalyze = parentRule && analyzedDefs.get(parentRule as any); if ( parentAnalyze && (parentAnalyze.type === 'topLevelClass' || parentAnalyze.type === 'part') ) { result.parentAnalyze = parentAnalyze; } // state const [amountToStateDef, stateDef] = STCustomState.parseStateValue( params.slice(index - 1), atRule, context.diagnostics ); if (stateDef !== undefined) { index += amountToStateDef; result.stateDef = stateDef; } else { result.match = false; } // leftover const amountTaken = index - 1; result.ranges!.leftoverValue.push(...params.slice(amountTaken)); const [amountToUnexpected] = findAnything(params, index); if (amountToUnexpected) { result.match = false; } return result; } function parsePseudoElementDefinition( context: FeatureContext, atRule: postcss.AtRule, params: BaseAstNode[] ) { const { analyzedDefs } = plugableRecord.getUnsafe(context.meta.data, dataKey); const result: Partial = { type: 'part', params, match: true, name: '', ranges: { pseudoElement: [], mapArrow: [], mapTo: [], leftoverValue: [] }, }; let index = 0; // collect pseudo element name const [amountToName, nameNode, nameInspectAmount] = findPseudoElementNode(params, 0); result.ranges!.pseudoElement.push(...params.slice(index, index + nameInspectAmount)); index += amountToName; if (nameNode) { result.name = nameNode.value; } else { // not a pseudo-element definition return false; } // get symbol to extend const parentRule = atRule.parent; const parentAnalyze = parentRule && analyzedDefs.get(parentRule as any); if (parentAnalyze?.type === 'topLevelClass' || parentAnalyze?.type === 'part') { result.parentAnalyze = parentAnalyze; } // collect mapped selector const [amountToMapping, mappingOpenNode, mapArrowInspectAmount] = findFatArrow(params, index, { stopOnFail: false, }); result.ranges!.mapArrow.push(...params.slice(index, index + mapArrowInspectAmount)); index += amountToMapping; if (mappingOpenNode) { result.mappedArrow = true; // selector result.ranges!.mapTo.push(...params.slice(index)); index = params.length - 1; const selectorStr = atRuleFullParams(atRule).slice(mappingOpenNode.end); const mappedSelectors = parseSelectorWithCache(selectorStr.trim()); const filteredSelector = mappedSelectors.length === 1 && filterCommentsAndSpaces(mappedSelectors[0]); if (filteredSelector && filteredSelector.nodes.length) { result.mappedSelector = filteredSelector; } } else { result.match = false; } // check unexpected extra result.ranges!.leftoverValue.push(...params.slice(index + 1)); return result; } function parseClassDefinition(atRule: postcss.AtRule, params: BaseAstNode[]) { const result: ParsedStClass = { type: 'topLevelClass', params, match: true, ranges: { class: [], extend: [], mapArrow: [], mapTo: [], leftoverValue: [] }, name: '', }; let index = 0; // top level class const [amountToClass, classNameNode, classInspectedAmount] = findNextClassNode(params, index, { stopOnFail: true, }); result.ranges.class.push(...params.slice(index, index + classInspectedAmount)); index += amountToClass; if (classNameNode) { result.name = classNameNode.value; } else { // not a class definition return false; } // collect extends class const [amountToExtends, extendsNode, extendInspectAmount] = findNextPseudoClassNode( params, index, { name: 'is', stopOnFail: true, stopOnMatch: (_node, index, nodes) => { const [amountToFatArrow] = findFatArrow(nodes, index, { stopOnFail: true }); return amountToFatArrow > 0; }, } ); if (extendsNode) { result.ranges.extend.push(...params.slice(index, index + extendInspectAmount)); index += amountToExtends; if (extendsNode.type === 'call') { const [amountToExtendedClass, nameNode] = findNextClassNode(extendsNode.args, 0, { stopOnFail: true, }); if (amountToExtendedClass) { index += amountToExtends; // check leftover nodes const [amountToUnexpectedNode] = findAnything( extendsNode.args, amountToExtendedClass ); if (!amountToUnexpectedNode) { result.extendedClass = nameNode!.value; } } } } // collect mapped selector const [amountToMapping, mappingOpenNode, mapArrowInspectAmount] = findFatArrow(params, index, { stopOnFail: false, }); if (mappingOpenNode) { result.ranges.mapArrow.push(...params.slice(index, index + mapArrowInspectAmount)); index += amountToMapping; // selector result.ranges.mapTo.push(...params.slice(index)); index = params.length; const selectorStr = atRuleFullParams(atRule).slice(mappingOpenNode.end); const mappedSelectors = parseSelectorWithCache(selectorStr); const filteredSelector = mappedSelectors.length === 1 && filterCommentsAndSpaces(mappedSelectors[0]); if (filteredSelector && filteredSelector.nodes.length) { result.mappedSelector = filteredSelector; } } // unexpected extra value result.ranges.leftoverValue.push(...params.slice(index)); return result; } function validateNestingInMapping( selector: ImmutableSelector, context: FeatureContext, atRule: postcss.AtRule ) { // check for unsupported & anywhere except first let invalid = false; let passedActualSelector = false; walkSelector(selector, (node) => { if (passedActualSelector && node.type === 'nesting') { context.diagnostics.report(diagnostics.MAPPING_UNSUPPORTED_NESTING(), { node: atRule, }); invalid = true; return walkSelector.stopAll; } else if (node.type !== 'comment' && node.type !== 'selector') { passedActualSelector = true; } return; }); return invalid; } function atRuleFullParams(atRule: postcss.AtRule) { const afterName = atRule.raws.afterName || ''; const between = atRule.raws.between || ''; return afterName + atRule.params + between; } function filterCommentsAndSpaces(selector: ImmutableSelector) { const filteredSelector: ImmutableSelector = { ...selector, after: '', before: '', nodes: selector.nodes.filter((node) => node.type !== 'comment'), }; return filteredSelector; }