@stylable/core
Version:
CSS for Components
313 lines (296 loc) • 9.71 kB
text/typescript
import { createFeature, FeatureContext } from './feature';
import * as STSymbol from './st-symbol';
import * as STImport from './st-import';
import type { Imported } from './st-import';
import type { StylableMeta } from '../stylable-meta';
import { plugableRecord } from '../helpers/plugable-record';
import { isInConditionalGroup } from '../helpers/rule';
import { namespace } from '../helpers/namespace';
import { globalValue, GLOBAL_FUNC } from '../helpers/global';
import type * as postcss from 'postcss';
import postcssValueParser from 'postcss-value-parser';
import { createDiagnosticReporter } from '../diagnostics';
export interface KeyframesSymbol {
_kind: 'keyframes';
alias: string;
name: string;
import?: Imported;
global?: boolean;
}
export interface KeyframesResolve {
meta: StylableMeta;
symbol: KeyframesSymbol;
}
export const reservedKeyFrames = [
'none',
'inherited',
'initial',
'unset',
/* single-timing-function */
'linear',
'ease',
'ease-in',
'ease-in-out',
'ease-out',
'step-start',
'step-end',
'start',
'end',
/* single-animation-iteration-count */
'infinite',
/* single-animation-direction */
'normal',
'reverse',
'alternate',
'alternate-reverse',
/* single-animation-fill-mode */
'forwards',
'backwards',
'both',
/* single-animation-play-state */
'running',
'paused',
];
export const diagnostics = {
ILLEGAL_KEYFRAMES_NESTING: createDiagnosticReporter(
'02001',
'error',
() => `illegal nested "@keyframes"`
),
MISSING_KEYFRAMES_NAME: createDiagnosticReporter(
'02002',
'error',
() => '"@keyframes" missing parameter'
),
MISSING_KEYFRAMES_NAME_INSIDE_GLOBAL: createDiagnosticReporter(
'02003',
'error',
() => `"@keyframes" missing parameter inside "${GLOBAL_FUNC}()"`
),
KEYFRAME_NAME_RESERVED: createDiagnosticReporter(
'02004',
'error',
(name: string) => `keyframes "${name}" is reserved`
),
UNKNOWN_IMPORTED_KEYFRAMES: createDiagnosticReporter(
'02005',
'error',
(name: string, path: string) =>
`cannot resolve imported keyframes "${name}" from stylesheet "${path}"`
),
};
const dataKey = plugableRecord.key<{
statements: postcss.AtRule[];
paths: Record<string, string[]>;
imports: string[];
}>('keyframes');
// HOOKS
STImport.ImportTypeHook.set(`keyframes`, (context, localName, importName, importDef) => {
addKeyframes({
context,
name: localName,
importName,
ast: importDef.rule,
importDef,
});
});
interface ResolvedSymbols {
record: Record<string, KeyframesResolve>;
locals: Set<string>;
}
export const hooks = createFeature<{
RESOLVED: ResolvedSymbols;
}>({
metaInit({ meta }) {
plugableRecord.set(meta.data, dataKey, { statements: [], paths: {}, imports: [] });
},
analyzeAtRule({ context, atRule }) {
let { params: name } = atRule;
// check nesting validity
if (!isInConditionalGroup(atRule, true)) {
context.diagnostics.report(diagnostics.ILLEGAL_KEYFRAMES_NESTING(), { node: atRule });
return;
}
// save keyframes declarations
const { statements: keyframesAsts } = plugableRecord.getUnsafe(context.meta.data, dataKey);
keyframesAsts.push(atRule);
// validate name
if (!name) {
context.diagnostics.report(diagnostics.MISSING_KEYFRAMES_NAME(), { node: atRule });
return;
}
//
const isStylable = context.meta.type === 'stylable';
let global: boolean | undefined;
const globalName = isStylable ? globalValue(name) : undefined;
if (globalName !== undefined) {
name = globalName;
global = true;
}
if (name === '') {
context.diagnostics.report(diagnostics.MISSING_KEYFRAMES_NAME_INSIDE_GLOBAL(), {
node: atRule,
});
return;
}
if (reservedKeyFrames.includes(name)) {
context.diagnostics.report(diagnostics.KEYFRAME_NAME_RESERVED(name), {
node: atRule,
word: name,
});
}
addKeyframes({
context,
name,
importName: name,
ast: atRule,
global: isStylable ? global : true,
});
},
transformResolve({ context }) {
const symbols = STSymbol.getAllByType(context.meta, `keyframes`);
const resolved: ResolvedSymbols = {
record: {},
locals: new Set(),
};
const resolvedSymbols = context.getResolvedSymbols(context.meta);
for (const [name, symbol] of Object.entries(symbols)) {
const res = resolvedSymbols.keyframes[name];
if (res) {
resolved.record[name] = res;
if (res.meta === context.meta) {
resolved.locals.add(name);
}
} else if (symbol.import) {
context.diagnostics.report(
diagnostics.UNKNOWN_IMPORTED_KEYFRAMES(symbol.name, symbol.import.request),
{
node: symbol.import.rule,
word: symbol.name,
}
);
}
}
return resolved;
},
transformAtRuleNode({ context, atRule, resolved }) {
const globalName =
context.meta.type === 'stylable' ? globalValue(atRule.params) : undefined;
const name = globalName ?? atRule.params;
if (!name) {
return;
}
const resolve = resolved.record[name];
/* js keyframes mixins won't have resolved keyframes */
atRule.params = resolve
? getTransformedName(resolve)
: globalName ?? namespace(name, context.meta.namespace);
},
transformDeclaration({ decl, resolved }) {
const parsed = postcssValueParser(decl.value);
// ToDo: improve by correctly parse & identify `animation-name`
// ToDo: handle symbols from js mixin
parsed.nodes.forEach((node) => {
const resolve = resolved.record[node.value];
const scoped = resolve && getTransformedName(resolve);
if (scoped) {
node.value = scoped;
}
});
decl.value = parsed.toString();
},
transformJSExports({ exports, resolved }) {
for (const name of resolved.locals) {
exports.keyframes[name] = getTransformedName(resolved.record[name]);
}
},
});
// API
export function getKeyframesStatements({ data }: StylableMeta): ReadonlyArray<postcss.AtRule> {
const { statements } = plugableRecord.getUnsafe(data, dataKey);
return statements;
}
export function get(meta: StylableMeta, name: string): KeyframesSymbol | undefined {
return STSymbol.get(meta, name, `keyframes`);
}
export function getAll(meta: StylableMeta): Record<string, KeyframesSymbol> {
return STSymbol.getAllByType(meta, `keyframes`);
}
function addKeyframes({
context,
name,
importName,
ast,
global,
importDef,
}: {
context: FeatureContext;
name: string;
importName: string;
ast: postcss.AtRule | postcss.Rule;
global?: boolean;
importDef?: Imported;
}) {
/**
* keyframes are safe to redeclare in case they are unique within their context (applied
* in different times/cases), for example 2 keyframes statements can override each other
* if 1 is applied on the root (always) and the other in @media (on some condition).
*
* > in case keyframes are imported, then no local keyframes
* > are allowed to override them (will report a warning).
*/
const isFirstInPath = addKeyframesDeclaration(context.meta, name, ast, !!importDef);
// first must not be `safeRedeclare`
const safeRedeclare = isFirstInPath && !!STSymbol.get(context.meta, name, `keyframes`);
// fields are confusing in this symbol:
// name: the import name if imported OR the local name
// alias: the local name
STSymbol.addSymbol({
context,
node: ast,
localName: name,
symbol: {
_kind: 'keyframes',
alias: name,
name: importName,
global,
import: importDef,
},
safeRedeclare,
});
}
function addKeyframesDeclaration(
meta: StylableMeta,
name: string,
origin: postcss.AtRule | postcss.Rule,
isImported: boolean
) {
let path = ``;
let current = origin.parent;
while (current) {
if (current.type === `rule`) {
path += ` -> ` + (current as postcss.Rule).selector;
} else if (current.type === `atrule`) {
path +=
` -> ` +
(current as postcss.AtRule).name +
` ` +
(current as postcss.AtRule).params;
}
current = current.parent as any;
}
const { paths, imports } = plugableRecord.getUnsafe(meta.data, dataKey);
if (!paths[path]) {
paths[path] = [];
}
const isFirstInPath = !paths[path].includes(name);
const isImportedBefore = imports.includes(name);
paths[path].push(name);
if (isImported) {
imports.push(name);
}
return isFirstInPath && !isImportedBefore;
}
function getTransformedName({ symbol, meta }: KeyframesResolve) {
return symbol.global ? symbol.alias : namespace(symbol.alias, meta.namespace);
}