@stylable/core
Version:
CSS for Components
166 lines (153 loc) • 6.66 kB
text/typescript
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<Record<string, AnalyzedCustomSelector>>('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<string, AnalyzedCustomSelector['ast']>);
}
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);
}