import { plugableRecord } from '../helpers/plugable-record'; import { createFeature } from './feature'; import { transformInlineCustomSelectorMap, transformInlineCustomSelectors, CustomSelectorMap, } from '../helpers/custom-selector'; import { parseSelectorWithCache } from '../helpers/selector'; import * as postcss from 'postcss'; import { SelectorList, stringifySelectorAst } from '@tokey/css-selector-parser'; import type { StylableMeta } from '../stylable-meta'; import { createDiagnosticReporter, Diagnostics } from '../diagnostics'; export const diagnostics = { UNKNOWN_CUSTOM_SELECTOR: createDiagnosticReporter( '18001', 'error', (selector: string) => `The selector '${selector}' is undefined` ), }; interface AnalyzedCustomSelector { selector: string; ast: SelectorList; isScoped: boolean; def: postcss.AtRule; } const dataKey = plugableRecord.key>('st-custom-selector'); export const CUSTOM_SELECTOR_RE = /:--[\w-]+/g; // HOOKS export const hooks = createFeature({ metaInit({ meta }) { plugableRecord.set(meta.data, dataKey, {}); }, analyzeAtRule({ context, atRule, analyzeRule }) { const params = atRule.params.split(/\s/); const customSelector = params.shift(); if (customSelector && customSelector.match(CUSTOM_SELECTOR_RE)) { const selector = atRule.params.replace(customSelector, '').trim(); const ast = parseSelectorWithCache(selector, { clone: true }); const isScoped = analyzeRule(postcss.rule({ selector, source: atRule.source }), { isScoped: false, originalNode: atRule, }); const analyzed = plugableRecord.getUnsafe(context.meta.data, dataKey); const name = customSelector.slice(3); analyzed[name] = { selector, ast, isScoped, def: atRule }; } else { // TODO: add warn there are two types one is not valid name and the other is empty name. } }, analyzeDone(context) { const analyzed = plugableRecord.getUnsafe(context.meta.data, dataKey); const customSelectors: CustomSelectorMap = {}; for (const [name, data] of Object.entries(analyzed)) { customSelectors[name] = data.ast; } const inlined = transformInlineCustomSelectorMap(customSelectors, (report) => { if (report.type === 'unknown' && analyzed[report.origin]) { const unknownSelector = `:--${report.unknown}`; context.diagnostics.report(diagnostics.UNKNOWN_CUSTOM_SELECTOR(unknownSelector), { node: analyzed[report.origin].def, word: unknownSelector, }); } else if (report.type === 'circular') { // ToDo: report error } }); // cache inlined selector for (const [name, ast] of Object.entries(inlined)) { analyzed[name].ast = ast; analyzed[name].selector = stringifySelectorAst(ast); } }, prepareAST({ context, node, toRemove }) { // called with experimentalSelectorInference=false // split selectors & remove definitions if (node.type === 'rule' && node.selector.match(CUSTOM_SELECTOR_RE)) { node.selector = transformCustomSelectorInline(context.meta, node.selector, { diagnostics: context.diagnostics, node, }); } else if (node.type === 'atrule' && node.name === 'custom-selector') { toRemove.push(node); } }, transformSelectorNode({ context, selectorContext, node }) { const customSelector = node.value.startsWith('--') && getCustomSelectorExpended(context.meta, node.value.slice(2)); if (customSelector) { const mappedSelectorAst = parseSelectorWithCache(customSelector, { clone: true }); const mappedContext = selectorContext.createNestedContext(mappedSelectorAst); selectorContext.scopeSelectorAst(mappedContext); const inferredSelector = selectorContext.experimentalSelectorInference ? mappedContext.inferredMultipleSelectors : mappedContext.inferredSelector; selectorContext.setNextSelectorScope(inferredSelector, node); // doesn't add to the resolved elements if (selectorContext.transform) { selectorContext.transformIntoMultiSelector(node, mappedSelectorAst); } } return !!customSelector; }, transformAtRuleNode({ atRule }) { if (atRule.name === 'custom-selector') { atRule.remove(); } }, }); // API export function isScoped(meta: StylableMeta, name: string) { const analyzed = plugableRecord.getUnsafe(meta.data, dataKey); return analyzed[name]?.isScoped; } export function getCustomSelector(meta: StylableMeta, name: string): SelectorList | undefined { return plugableRecord.getUnsafe(meta.data, dataKey)[name]?.ast; } export function getCustomSelectors(meta: StylableMeta) { const analyzed = plugableRecord.getUnsafe(meta.data, dataKey); return Object.entries(analyzed).reduce((acc, [name, { ast }]) => { acc[name] = ast; return acc; }, {} as Record); } export function getCustomSelectorExpended(meta: StylableMeta, name: string): string | undefined { const analyzed = plugableRecord.getUnsafe(meta.data, dataKey); return analyzed[name]?.selector; } export function getCustomSelectorNames(meta: StylableMeta): string[] { const analyzed = plugableRecord.getUnsafe(meta.data, dataKey); return Object.keys(analyzed).map((name) => `:--${name}`); } export function transformCustomSelectorInline( meta: StylableMeta, selector: string, options: { diagnostics?: Diagnostics; node?: postcss.Node } = {} ) { const ast = parseSelectorWithCache(selector, { clone: true }); const analyzed = plugableRecord.getUnsafe(meta.data, dataKey); const inlined = transformInlineCustomSelectors( ast, (name) => analyzed[name]?.ast, (report) => { if (options.diagnostics && options.node) { const unknownSelector = `:--${report.unknown}`; options.diagnostics.report(diagnostics.UNKNOWN_CUSTOM_SELECTOR(unknownSelector), { node: options.node, word: unknownSelector, }); } } ); return stringifySelectorAst(inlined); }