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; 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; locals: Set; } 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 { 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 { 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); }