UNPKG

@stylable/core

Version:

CSS for Components

684 lines (652 loc) 23.2 kB
import { createFeature, FeatureContext, FeatureTransformContext } from './feature'; import { generalDiagnostics } from './diagnostics'; import * as STSymbol from './st-symbol'; import type { StylableSymbol } from './st-symbol'; import type { ImportSymbol } from './st-import'; import type { ElementSymbol } from './css-type'; import type * as STStructure from './st-structure'; import * as STCustomState from './st-custom-state'; import { getOriginDefinition } from '../helpers/resolve'; import { namespace } from '../helpers/namespace'; import { namespaceEscape, unescapeCSS } from '../helpers/escape'; import { getNamedArgs } from '../helpers/value'; import { convertToClass, stringifySelector, isSimpleSelector, parseSelectorWithCache, convertToPseudoClass, convertToSelector, } from '../helpers/selector'; import { getAlias } from '../stylable-utils'; import type { StylableMeta } from '../stylable-meta'; import { validateRuleStateDefinition } from '../helpers/custom-state'; import type { Stylable } from '../stylable'; import { ImmutableClass, Class, SelectorNode, ImmutableSelectorNode, stringifySelectorAst, SelectorNodes, } from '@tokey/css-selector-parser'; import * as postcss from 'postcss'; import { basename } from 'path'; import { createDiagnosticReporter } from '../diagnostics'; import postcssValueParser from 'postcss-value-parser'; import { plugableRecord } from '../helpers/plugable-record'; export interface StPartDirectives extends STStructure.HasParts, Partial<STCustomState.HasStates> { '-st-root'?: boolean; '-st-extends'?: ImportSymbol | ClassSymbol | ElementSymbol; '-st-global'?: SelectorNode[]; } const stPartDirectives = { '-st-root': true, '-st-states': true, '-st-extends': true, '-st-global': true, } as const; export interface ClassSymbol extends StPartDirectives { _kind: 'class'; name: string; alias?: ImportSymbol; scoped?: string; // ToDo: check if in use } export const diagnostics = { INVALID_FUNCTIONAL_SELECTOR: generalDiagnostics.INVALID_FUNCTIONAL_SELECTOR, UNSCOPED_CLASS: createDiagnosticReporter( '00002', 'warning', (name: string) => `unscoped class "${name}" will affect all elements of the same type in the document` ), STATE_DEFINITION_IN_ELEMENT: createDiagnosticReporter( '11002', 'error', () => 'cannot define pseudo states inside a type selector' ), STATE_DEFINITION_IN_COMPLEX: createDiagnosticReporter( '11003', 'error', () => 'cannot define pseudo states inside complex selectors' ), OVERRIDE_TYPED_RULE: createDiagnosticReporter( '11006', 'warning', (key: string, name: string) => `override "${key}" on typed rule "${name}"` ), CANNOT_RESOLVE_EXTEND: createDiagnosticReporter( '11004', 'error', (name: string) => `cannot resolve '-st-extends' type for '${name}'` ), CANNOT_EXTEND_IN_COMPLEX: createDiagnosticReporter( '11005', 'error', () => `cannot define "-st-extends" inside a complex selector` ), EMPTY_ST_GLOBAL: createDiagnosticReporter( '00003', 'error', () => `-st-global must contain a valid selector` ), UNSUPPORTED_MULTI_SELECTORS_ST_GLOBAL: createDiagnosticReporter( '00004', 'error', () => `unsupported multi selector in -st-global` ), UNSUPPORTED_COMPLEX_SELECTOR: createDiagnosticReporter( '00010', 'error', () => `unsupported complex selector` ), IMPORT_ISNT_EXTENDABLE: createDiagnosticReporter( '00005', 'error', () => 'import is not extendable' ), CANNOT_EXTEND_UNKNOWN_SYMBOL: createDiagnosticReporter( '00006', 'error', (name: string) => `cannot extend unknown symbol "${name}"` ), CANNOT_EXTEND_JS: createDiagnosticReporter( '00007', 'error', () => 'JS import is not extendable' ), UNKNOWN_IMPORT_ALIAS: createDiagnosticReporter( '00008', 'error', (name: string) => `cannot use alias for unknown import "${name}"` ), DISABLED_DIRECTIVE: createDiagnosticReporter( '00009', 'error', (className: string, directive: keyof typeof stPartDirectives) => { const alternative = directive === '-st-extends' ? ` use "@st .${className} :is(.base)" instead` : directive === '-st-global' ? `use "@st .${className} => :global(<selector>)" instead` : directive === '-st-states' ? `use "@st .${className} { @st .state; }" instead` : ''; return `cannot use ${directive} on .${className} since class is defined with "@st" - ${alternative}`; } ), }; const dataKey = plugableRecord.key<{ classesDefinedWithAtSt: Set<string>; }>('st-structure'); // HOOKS export const hooks = createFeature<{ SELECTOR: Class; IMMUTABLE_SELECTOR: ImmutableClass; RESOLVED: Record<string, { classes: string; isLocal: boolean }>; }>({ metaInit({ meta }) { plugableRecord.set(meta.data, dataKey, { classesDefinedWithAtSt: new Set<string>(), }); }, analyzeSelectorNode({ context, node, rule }): void { if (node.nodes) { // error on functional class context.diagnostics.report( diagnostics.INVALID_FUNCTIONAL_SELECTOR(`.` + node.value, `class`), { node: rule, word: stringifySelector(node), } ); } addClass(context, node.value, rule); }, analyzeDeclaration({ context, decl }) { if (context.meta.type === 'stylable' && isDirectiveDeclaration(decl)) { handleDirectives(context, decl); } }, transformResolve({ context }) { const resolvedSymbols = context.getResolvedSymbols(context.meta); const locals: Record<string, { classes: string; isLocal: boolean }> = {}; for (const [localName, resolved] of Object.entries(resolvedSymbols.class)) { const exportedClasses = []; let first = true; // collect list of css classes for exports for (const { meta, symbol } of resolved) { if (!first && symbol[`-st-root`]) { // extended stylesheet root: stop collection as root is expected to // be placed by inner component, for example in <Button class={classes.primaryBtn} /> // `primaryBtn` shouldn't contain `button__root` as it is placed by the Button component break; } first = false; if (symbol[`-st-global`]) { // collect global override just in case of // compound set of CSS classes let isOnlyClasses = true; const globalClasses = symbol[`-st-global`].reduce<string[]>( (globalClasses, node) => { if (node.type === `class`) { globalClasses.push(node.value); context.meta.globals[node.value] = true; } else { isOnlyClasses = false; } return globalClasses; }, [] ); if (isOnlyClasses) { exportedClasses.push(...globalClasses); } continue; } if (symbol.alias && !symbol[`-st-extends`]) { continue; } exportedClasses.push(namespace(symbol.name, meta.namespace)); } const classNames = unescapeCSS(exportedClasses.join(' ')); if (classNames) { const directResolve = resolved[0]; const isLocal = directResolve.meta === context.meta && !directResolve.symbol.alias; locals[localName] = { classes: classNames, isLocal }; } } return locals; }, transformSelectorNode({ context, selectorContext, node }) { const { originMeta, resolver } = selectorContext; const resolvedSymbols = context.getResolvedSymbols(context.meta); const resolved = resolvedSymbols.class[node.value] || [ // used to namespace classes from js mixins since js mixins // are scoped in the context of the mixed-in stylesheet // which might not have a definition for the mixed-in class { _kind: 'css', meta: originMeta, symbol: createSymbol({ name: node.value }) }, ]; selectorContext.setNextSelectorScope(resolved, node, node.value); const { symbol, meta } = getOriginDefinition(resolved); if (selectorContext.originMeta === meta && symbol[`-st-states`]) { // ToDo: refactor out to transformer validation phase validateRuleStateDefinition( selectorContext.selectorStr, selectorContext.ruleOrAtRule, context.meta, resolver, context.diagnostics ); } if (selectorContext.transform) { namespaceClass(meta, symbol, node); } }, transformJSExports({ exports, resolved }) { for (const [localName, { classes, isLocal }] of Object.entries(resolved)) { if (isLocal) { exports.classes[localName] = classes; } } }, }); // API export class StylablePublicApi { constructor(private stylable: Stylable) {} public transformIntoSelector(meta: StylableMeta, name: string): string | undefined { const localSymbol = STSymbol.get(meta, name); const resolved = localSymbol?._kind === 'import' ? this.stylable.resolver.deepResolve(localSymbol) : { _kind: 'css', meta, symbol: localSymbol }; if (resolved?._kind !== 'css' || resolved.symbol?._kind !== 'class') { return undefined; } const node: Class = { type: 'class', value: '', start: 0, end: 0, dotComments: [], }; namespaceClass(resolved.meta, resolved.symbol, node, false); return stringifySelectorAst(node); } } export function get(meta: StylableMeta, name: string): ClassSymbol | undefined { return STSymbol.get(meta, name, `class`); } export function getAll(meta: StylableMeta): Record<string, ClassSymbol> { return STSymbol.getAllByType(meta, `class`); } export function createSymbol(input: Partial<ClassSymbol> & { name: string }): ClassSymbol { const parts = input['-st-parts'] || {}; return { ...input, _kind: 'class', '-st-parts': parts }; } export function addClass(context: FeatureContext, name: string, rule?: postcss.Node): ClassSymbol { let symbol = STSymbol.get(context.meta, name, `class`); if (!symbol) { let alias = STSymbol.get(context.meta, name); if (alias && alias._kind !== 'import') { alias = undefined; } symbol = STSymbol.addSymbol({ context, symbol: createSymbol({ name, alias }), node: rule, safeRedeclare: !!alias, }) as ClassSymbol; } // mark native css as global if (context.meta.type === 'css' && !symbol['-st-global']) { symbol['-st-global'] = [ { type: 'class', value: name, dotComments: [], start: 0, end: 0, }, ]; } return symbol; } export function namespaceClass( meta: StylableMeta, symbol: StylableSymbol, node: SelectorNode, // ToDo: check this is the correct type, should this be inline selector? wrapInGlobal = true ) { if (`-st-global` in symbol && symbol[`-st-global`]) { // change node to `-st-global` value if (wrapInGlobal) { const globalMappedNodes = symbol[`-st-global`]; convertToPseudoClass(node, 'global', [ { type: 'selector', nodes: globalMappedNodes, after: '', before: '', end: 0, start: 0, }, ]); } else { const flatNode = convertToSelector(node); const globalMappedNodes = symbol[`-st-global`]; flatNode.nodes = globalMappedNodes; } } else { node = convertToClass(node); node.value = namespaceEscape(symbol.name, meta.namespace); } } function getNamespacedClass(meta: StylableMeta, symbol: StylableSymbol) { if (`-st-global` in symbol && symbol[`-st-global`]) { const selector = symbol[`-st-global`]; return stringifySelectorAst(selector as any); } else { return '.' + namespaceEscape(symbol.name, meta.namespace); } } export function addDevRules({ getResolvedSymbols, meta }: FeatureTransformContext) { const resolvedSymbols = getResolvedSymbols(meta); for (const resolved of Object.values(resolvedSymbols.class)) { const a = resolved[0]; if (resolved.length > 1 && a.symbol['-st-extends']) { const b = resolved[resolved.length - 1]; meta.targetAst!.append( createWarningRule( '.' + b.symbol.name, getNamespacedClass(b.meta, b.symbol), basename(b.meta.source), '.' + a.symbol.name, getNamespacedClass(a.meta, a.symbol), basename(a.meta.source) ) ); } } } export function createWarningRule( extendedNode: string, scopedExtendedNode: string, extendedFile: string, extendingNode: string, scopedExtendingNode: string, extendingFile: string ) { const message = `"class extending component '${extendingNode} => ${scopedExtendingNode}' in stylesheet '${extendingFile}' was set on a node that does not extend '${extendedNode} => ${scopedExtendedNode}' from stylesheet '${extendedFile}'" !important`; return postcss.rule({ raws: { between: ' ' }, selector: `${scopedExtendingNode}:not(${scopedExtendedNode})::before`, nodes: [ postcss.decl({ prop: 'content', value: message, }), postcss.decl({ prop: 'display', value: `block !important`, }), postcss.decl({ prop: 'font-family', value: `monospace !important`, }), postcss.decl({ prop: 'background-color', value: `red !important`, }), postcss.decl({ prop: 'color', value: `white !important`, }), ], }); } export function validateClassScoping({ context, classSymbol, locallyScoped, reportUnscoped, node, nodes, index, rule, }: { context: FeatureContext; classSymbol: ClassSymbol; locallyScoped: boolean; reportUnscoped: boolean; node: ImmutableClass; nodes: ImmutableSelectorNode[]; index: number; rule: postcss.Rule; }): boolean { if (context.meta.type !== 'stylable') { // ignore in native CSS return true; } if (!classSymbol.alias) { return true; } else if (locallyScoped === false) { if (checkForScopedNodeAfter(context, rule, nodes, index) === false) { if (reportUnscoped) { context.diagnostics.report(diagnostics.UNSCOPED_CLASS(node.value), { node: rule, word: node.value, }); } return false; } else { return true; } } return locallyScoped; } // ToDo: support more complex cases (e.g. `:is`) export function checkForScopedNodeAfter( context: FeatureContext, rule: postcss.Rule, nodes: ImmutableSelectorNode[], index: number ) { for (let i = index + 1; i < nodes.length; i++) { const node = nodes[i]; if (!node) { // ToDo: can this get here??? break; } if (node.type === 'combinator') { break; } if (node.type === 'class') { const name = node.value; const classSymbol = addClass(context, name, rule); if (classSymbol && !classSymbol.alias) { return true; } } } return false; } function isDirectiveDeclaration( decl: postcss.Declaration ): decl is postcss.Declaration & { prop: keyof typeof stPartDirectives } { return decl.prop in stPartDirectives; } export function disableDirectivesForClass(context: FeatureContext, className: string) { // ToDo: move directive analyze to @st-structure // called when class is defined with @st const { classesDefinedWithAtSt } = plugableRecord.getUnsafe(context.meta.data, dataKey); classesDefinedWithAtSt.add(className); } function handleDirectives( context: FeatureContext, decl: postcss.Declaration & { prop: keyof typeof stPartDirectives } ) { const rule = decl.parent as postcss.Rule; if (rule?.type !== 'rule') { return; } const isSimplePerSelector = isSimpleSelector(rule.selector); const type = isSimplePerSelector.reduce((accType, { type }) => { return !accType ? type : accType !== type ? `complex` : type; }, `` as (typeof isSimplePerSelector)[number]['type']); const isSimple = type !== `complex`; const { classesDefinedWithAtSt } = plugableRecord.getUnsafe(context.meta.data, dataKey); if (type === 'class' && classesDefinedWithAtSt.has(rule.selector.replace('.', ''))) { context.diagnostics.report( diagnostics.DISABLED_DIRECTIVE(rule.selector.replace('.', ''), decl.prop), { node: decl, } ); return; } else if (decl.prop === `-st-states`) { if (isSimple && type !== 'type') { extendTypedRule( context, decl, rule.selector, `-st-states`, STCustomState.parsePseudoStates(decl.value, decl, context.diagnostics) ); } else { if (type === 'type') { context.diagnostics.report(diagnostics.STATE_DEFINITION_IN_ELEMENT(), { node: decl, }); } else { context.diagnostics.report(diagnostics.STATE_DEFINITION_IN_COMPLEX(), { node: decl, }); } } } else if (decl.prop === `-st-extends`) { if (isSimple) { const parsed = parseStExtends(decl.value); const symbolName = parsed.types[0] && parsed.types[0].symbolName; const extendsRefSymbol = STSymbol.get(context.meta, symbolName)!; if ( (extendsRefSymbol && (extendsRefSymbol._kind === 'import' || extendsRefSymbol._kind === 'class' || extendsRefSymbol._kind === 'element')) || decl.value === context.meta.root ) { extendTypedRule( context, decl, rule.selector, `-st-extends`, getAlias(extendsRefSymbol) || extendsRefSymbol ); } else { context.diagnostics.report(diagnostics.CANNOT_RESOLVE_EXTEND(decl.value), { node: decl, word: decl.value, }); } } else { context.diagnostics.report(diagnostics.CANNOT_EXTEND_IN_COMPLEX(), { node: decl, }); } } else if (decl.prop === `-st-global`) { if (isSimple && type !== 'type') { // set class global mapping const name = rule.selector.replace('.', ''); const classSymbol = get(context.meta, name); if (classSymbol) { const globalSelectorAst = parseStGlobal(context, decl); if (globalSelectorAst) { classSymbol[`-st-global`] = globalSelectorAst; } } } else { // TODO: diagnostics - scoped on none class } } } export function extendTypedRule( context: FeatureContext, node: postcss.Node, selector: string, key: keyof StPartDirectives, value: any ) { const name = selector.replace('.', ''); const typedRule = STSymbol.get(context.meta, name) as ClassSymbol | ElementSymbol; if (typedRule && typedRule[key]) { context.diagnostics.report(diagnostics.OVERRIDE_TYPED_RULE(key, name), { node, word: name, }); } if (typedRule) { typedRule[key] = value; } } export interface ArgValue { type: string; value: string; } export interface ExtendsValue { symbolName: string; args: ArgValue[][] | null; } export function parseStExtends(value: string) { const ast = postcssValueParser(value); const types: ExtendsValue[] = []; ast.walk((node) => { if (node.type === 'function') { const args = getNamedArgs(node); types.push({ symbolName: node.value, args, }); return false; } else if (node.type === 'word') { types.push({ symbolName: node.value, args: null, }); } return undefined; }, false); return { ast, types, }; } function parseStGlobal( context: FeatureContext, decl: postcss.Declaration ): SelectorNodes | undefined { const selector = parseSelectorWithCache(decl.value.replace(/^['"]/, '').replace(/['"]$/, ''), { clone: true, }); if (!selector[0]) { context.diagnostics.report(diagnostics.EMPTY_ST_GLOBAL(), { node: decl, }); return; } else if (selector.length > 1) { context.diagnostics.report(diagnostics.UNSUPPORTED_MULTI_SELECTORS_ST_GLOBAL(), { node: decl, }); return; } else { for (const node of selector[0].nodes) { if (node.type === 'combinator') { context.diagnostics.report(diagnostics.UNSUPPORTED_COMPLEX_SELECTOR(), { node: decl, }); return; } } } return selector[0].nodes; }