@stylable/core
Version:
CSS for Components
468 lines (441 loc) • 15.8 kB
text/typescript
import { createFeature, FeatureContext, FeatureTransformContext } from './feature';
import * as STSymbol from './st-symbol';
import type { ImportSymbol } from './st-import';
import {
validateAtProperty,
validateCustomPropertyName,
generateScopedCSSVar,
atPropertyValidationWarnings,
} from '../helpers/css-custom-property';
import type { Stylable } from '../stylable';
import { validateAllowedNodesUntil, stringifyFunction } from '../helpers/value';
import { globalValue, GLOBAL_FUNC } from '../helpers/global';
import { plugableRecord } from '../helpers/plugable-record';
import { createDiagnosticReporter, Diagnostics } from '../diagnostics';
import type { StylableMeta } from '../stylable-meta';
import {
type StylableResolver,
type CSSResolve,
createSymbolResolverWithCache,
} from '../stylable-resolver';
import type * as postcss from 'postcss';
// ToDo: refactor out - parse once and pass to hooks
import postcssValueParser, { WordNode } from 'postcss-value-parser';
export interface CSSVarSymbol {
_kind: 'cssVar';
name: string;
global: boolean;
alias: ImportSymbol | undefined;
}
export const diagnostics = {
...atPropertyValidationWarnings,
ILLEGAL_CSS_VAR_USE: createDiagnosticReporter(
'01005',
'error',
(name: string) =>
`a custom css property must begin with "--" (double-dash), but received "${name}"`
),
ILLEGAL_CSS_VAR_ARGS: createDiagnosticReporter(
'01006',
'error',
(name: string) =>
`custom property "${name}" usage (var()) must receive comma separated values`
),
DEPRECATED_ST_GLOBAL_CUSTOM_PROPERTY: createDiagnosticReporter(
'01007',
'info',
() =>
`"st-global-custom-property" is deprecated and will be removed in the next version. Use "@property" with ${GLOBAL_FUNC}`
),
GLOBAL_CSS_VAR_MISSING_COMMA: createDiagnosticReporter(
'01008',
'error',
(name: string) =>
`"@st-global-custom-property" received the value "${name}", but its values must be comma separated`
),
ILLEGAL_GLOBAL_CSS_VAR: createDiagnosticReporter(
'01009',
'error',
(name: string) =>
`"@st-global-custom-property" received the value "${name}", but it must begin with "--" (double-dash)`
),
MISSING_PROP_NAME: createDiagnosticReporter(
'01010',
'error',
() => `missing custom property name for "var(--[PROP NAME])"`
),
UNDEFINED_CSS_CUSTOM_PROP: createDiagnosticReporter(
'01011',
'error',
(name) =>
`Undefined "${name}" custom property. Please define the property using '@property' or import it with '@st-import' when 'strictCustomProperty' is enabled.`
),
};
const dataKey = plugableRecord.key<{
stCustomGlobalProperty: Record<string, CSSVarSymbol>;
typedDefinitions: Record<string, postcss.AtRule[]>;
}>('custom-property');
// HOOKS
interface ResolvedSymbols {
localToGlobal: Record<string, string>;
locals: Set<string>;
}
export const hooks = createFeature<{
RESOLVED: ResolvedSymbols;
}>({
metaInit({ meta }) {
plugableRecord.set(meta.data, dataKey, {
stCustomGlobalProperty: {},
typedDefinitions: {},
});
},
analyzeInit(context) {
// ToDo: move to `STImport.ImportTypeHook`
for (const [symbolName, symbol] of Object.entries(
STSymbol.getAllByType(context.meta, `import`)
)) {
if (validateCustomPropertyName(symbolName)) {
const importSymbol = STSymbol.get(context.meta, symbolName, `import`);
if (!importSymbol) {
console.warn(
`imported symbol "${symbolName}" not found on "${context.meta.source}"`
);
continue;
}
addCSSProperty({
context,
node: symbol.import.rule,
name: symbolName,
global: false,
final: true,
alias: importSymbol,
});
}
}
},
analyzeAtRule({ context, atRule }) {
const isStylable = context.meta.type === 'stylable';
if (atRule.name === `property`) {
let name = atRule.params;
let global = !isStylable;
// check global
const globalVarName = isStylable ? globalValue(name) : undefined;
if (globalVarName !== undefined) {
name = globalVarName.trim();
global = true;
}
const { stCustomGlobalProperty, typedDefinitions } = plugableRecord.getUnsafe(
context.meta.data,
dataKey
);
// handle conflict with deprecated `@st-global-custom-property`
if (stCustomGlobalProperty[name]) {
global = true;
}
addCSSProperty({
context,
node: atRule,
name,
global,
final: true,
});
validateAtProperty(atRule, context.diagnostics);
// save reference to runtime definitions
if (atRule.nodes) {
typedDefinitions[name] ??= [];
typedDefinitions[name].push(atRule);
}
} else if (atRule.name === `st-global-custom-property` && isStylable) {
analyzeDeprecatedStGlobalCustomProperty(context, atRule);
}
},
analyzeDeclaration({ context, decl }) {
// register prop
if (validateCustomPropertyName(decl.prop)) {
addCSSProperty({
context,
node: decl,
name: decl.prop,
global: context.meta.type === 'css',
final: false,
});
}
// register value
if (decl.value.includes('var(')) {
analyzeDeclValueVarCalls(context, decl);
}
},
prepareAST({ context, node, toRemove }) {
if (
node.type === `atrule` &&
node.name === 'st-global-custom-property' &&
context.meta.type === 'stylable'
) {
toRemove.push(node);
}
},
transformResolve({ context: { meta, getResolvedSymbols } }) {
const customPropsMapping: ResolvedSymbols = {
localToGlobal: {},
locals: new Set(),
};
const resolvedSymbols = getResolvedSymbols(meta);
for (const [localVarName, localSymbol] of Object.entries(
STSymbol.getAllByType(meta, `cssVar`)
)) {
const resolve = resolveFinalSymbol(meta, localSymbol, resolvedSymbols);
customPropsMapping.localToGlobal[localVarName] = getTransformedName(resolve);
if (resolve.meta === meta) {
customPropsMapping.locals.add(localVarName);
}
}
return customPropsMapping;
},
transformAtRuleNode({ context, atRule, resolved }) {
if (atRule.name !== `property`) {
return;
}
if (atRule.nodes?.length) {
const propName = globalValue(atRule.params) || atRule.params;
if (resolved.localToGlobal[propName]) {
atRule.params = resolved.localToGlobal[propName] || atRule.params;
}
} else if (context.meta.type === 'stylable') {
// remove `@property` with no body
atRule.remove();
}
},
transformDeclaration({ decl, resolved }) {
decl.prop = resolved.localToGlobal[decl.prop] || decl.prop;
},
transformValue({ node, data: { meta }, context: { getResolvedSymbols } }) {
const { value } = node;
const varWithPrefix = node.nodes[0]?.value || ``;
if (validateCustomPropertyName(varWithPrefix)) {
transformPropertyIdent(meta, node.nodes[0], getResolvedSymbols);
}
// handle default values - ToDo: check if required
if (node.nodes.length > 2) {
node.resolvedValue = stringifyFunction(value, node);
}
},
transformJSExports({ exports, resolved }) {
for (const varName of resolved.locals) {
exports.vars[varName.slice(2)] = resolved.localToGlobal[varName];
}
},
});
// API
export function transformPropertyIdent(
meta: StylableMeta,
node: WordNode,
getResolvedSymbols: FeatureTransformContext['getResolvedSymbols']
) {
const varWithPrefix = node.value || '';
const resolvedSymbols = getResolvedSymbols(meta);
const localSymbol = STSymbol.get(meta, varWithPrefix, `cssVar`);
if (localSymbol) {
node.value = getTransformedName(resolveFinalSymbol(meta, localSymbol, resolvedSymbols));
}
}
export function get(meta: StylableMeta, name: string): CSSVarSymbol | undefined {
return STSymbol.get(meta, name, `cssVar`);
}
function resolveFinalSymbol(
meta: StylableMeta,
localSymbol: CSSVarSymbol,
resolvedSymbols: ReturnType<FeatureTransformContext['getResolvedSymbols']>
) {
return (
resolvedSymbols.cssVar[localSymbol.name] || {
// fallback to local namespace
_kind: `css`,
symbol: localSymbol,
meta,
}
);
}
export function addCSSProperty({
context,
node,
name,
global,
final,
alias,
}: {
context: FeatureContext;
node: postcss.Declaration | postcss.AtRule | postcss.Rule;
name: string;
global: boolean;
final: boolean;
alias?: ImportSymbol;
}) {
// validate indent
if (!validateCustomPropertyName(name)) {
context.diagnostics.report(diagnostics.ILLEGAL_CSS_VAR_USE(name), {
node,
word: name,
});
return;
}
// usages bailout: addition of weak definition reference `--x: var(--x)`
if (!final) {
const existing = STSymbol.get(context.meta, name, `cssVar`);
if (existing) {
// already defined
return;
// eslint-disable-next-line no-constant-condition
} else if (context.meta.type === 'stylable' && context.meta.flags.strictCustomProperty) {
// strict mode
context.diagnostics.report(diagnostics.UNDEFINED_CSS_CUSTOM_PROP(name), {
node,
word: name,
});
}
}
// define symbol
STSymbol.addSymbol({
context,
symbol: {
_kind: 'cssVar',
name: name,
global,
alias,
},
safeRedeclare: !final || !!alias,
node,
});
}
const UNKNOWN_LOCATION = Object.freeze({
offset: -1,
line: -1,
column: -1,
});
export class StylablePublicApi {
constructor(private stylable: Stylable) {}
public getProperties(meta: StylableMeta) {
const results: Record<
string,
{
meta: StylableMeta;
localName: string;
targetName: string;
source: {
meta: StylableMeta;
start: postcss.Position;
end: postcss.Position;
};
}
> = {};
const topLevelDiagnostics = new Diagnostics();
const getResolvedSymbols = createSymbolResolverWithCache(
this.stylable.resolver,
topLevelDiagnostics
);
const { cssVar } = getResolvedSymbols(meta);
for (const [name, symbol] of Object.entries(cssVar)) {
const defAst = STSymbol.getSymbolAstNode(symbol.meta, symbol.symbol);
results[name] = {
meta: symbol.meta,
localName: symbol.symbol.name,
targetName: getTransformedName(symbol),
source: {
meta: symbol.meta,
start: defAst?.source?.start || UNKNOWN_LOCATION,
end: defAst?.source?.end || UNKNOWN_LOCATION,
},
};
}
return results;
}
}
function analyzeDeclValueVarCalls(context: FeatureContext, decl: postcss.Declaration) {
const parsed = postcssValueParser(decl.value);
parsed.walk((node) => {
if (node.type === 'function' && node.value === 'var' && node.nodes) {
const varName = node.nodes[0];
if (!varName) {
context.diagnostics.report(diagnostics.MISSING_PROP_NAME(), {
node: decl,
});
return;
}
if (!validateAllowedNodesUntil(node, 1)) {
const args = postcssValueParser.stringify(node.nodes);
context.diagnostics.report(diagnostics.ILLEGAL_CSS_VAR_ARGS(args), {
node: decl,
word: args,
});
}
addCSSProperty({
context,
name: postcssValueParser.stringify(varName)?.trim() || ``,
node: decl,
global: context.meta.type === 'css',
final: false,
});
}
});
}
function analyzeDeprecatedStGlobalCustomProperty(context: FeatureContext, atRule: postcss.AtRule) {
// report deprecation
context.diagnostics.report(diagnostics.DEPRECATED_ST_GLOBAL_CUSTOM_PROPERTY(), {
node: atRule,
});
//
const cssVarsByComma = atRule.params.split(',');
const cssVarsBySpacing = atRule.params
.trim()
.split(/\s+/g)
.filter((s) => s !== ',');
if (cssVarsBySpacing.length > cssVarsByComma.length) {
context.diagnostics.report(diagnostics.GLOBAL_CSS_VAR_MISSING_COMMA(atRule.params), {
node: atRule,
word: atRule.params,
});
return;
}
for (const entry of cssVarsByComma) {
const name = entry.trim();
if (validateCustomPropertyName(name)) {
// ToDo: change to modify global instead of override
addCSSProperty({
context,
node: atRule,
name,
global: true,
final: true,
});
// keep track of defined props through `@st-custom-global-property` in order
// to not override the default with following `@property` definitions
const { stCustomGlobalProperty } = plugableRecord.getUnsafe(context.meta.data, dataKey);
stCustomGlobalProperty[name] = STSymbol.get(context.meta, name, `cssVar`)!;
} else {
context.diagnostics.report(diagnostics.ILLEGAL_GLOBAL_CSS_VAR(name), {
node: atRule,
word: name,
});
}
}
}
export function getRuntimeTypedDefinitionNames(meta: StylableMeta) {
const { typedDefinitions } = plugableRecord.getUnsafe(meta.data, dataKey);
return Object.keys(typedDefinitions);
}
export function getTransformedName({ symbol, meta }: CSSResolve<CSSVarSymbol>) {
return symbol.global ? symbol.name : generateScopedCSSVar(meta.namespace, symbol.name.slice(2));
}
export function scopeCSSVar(resolver: StylableResolver, meta: StylableMeta, symbolName: string) {
const importedVar = resolver.deepResolve(STSymbol.get(meta, symbolName));
if (
importedVar &&
importedVar._kind === 'css' &&
importedVar.symbol &&
importedVar.symbol._kind === 'cssVar'
) {
importedVar;
return getTransformedName(importedVar as CSSResolve<CSSVarSymbol>);
}
const cssVar = STSymbol.get(meta, symbolName, `cssVar`);
return cssVar?.global ? symbolName : generateScopedCSSVar(meta.namespace, symbolName.slice(2));
}