import { createFeature, FeatureContext } from './feature'; import * as STSymbol from './st-symbol'; import * as STImport from './st-import'; import * as CSSCustomProperty from './css-custom-property'; import type { StylableMeta } from '../stylable-meta'; import { createDiagnosticReporter } from '../diagnostics'; import { plugableRecord } from '../helpers/plugable-record'; import { namespace } from '../helpers/namespace'; import { globalValueFromFunctionNode, GLOBAL_FUNC } from '../helpers/global'; import valueParser, { WordNode } from 'postcss-value-parser'; import type * as postcss from 'postcss'; export interface ContainerSymbol { _kind: 'container'; name: string; alias: string; global?: boolean; import?: STImport.Imported; } export interface ResolvedContainer { meta: StylableMeta; symbol: ContainerSymbol; } export const diagnostics = { UNEXPECTED_DECL_VALUE: createDiagnosticReporter( '20001', 'error', (value: string) => `unexpected value: ${value}` ), UNKNOWN_DECL_TYPE: createDiagnosticReporter( '20002', 'error', (value: string) => `unknown container type: ${value}` ), MISSING_DECL_TYPE: createDiagnosticReporter( '20003', 'error', () => `missing container shorthand type` ), INVALID_CONTAINER_NAME: createDiagnosticReporter( '20004', 'error', (value: string) => `invalid container name: ${value}` ), UNRESOLVED_CONTAINER_NAME: createDiagnosticReporter( '20005', 'error', (value: string) => `unresolved container name: ${value}` ), UNKNOWN_IMPORTED_CONTAINER: createDiagnosticReporter( '20006', 'error', (name: string, path: string) => `cannot resolve imported container name "${name}" from stylesheet "${path}"` ), MISSING_CONTAINER_NAME_INSIDE_GLOBAL: createDiagnosticReporter( '20007', 'warning', () => `Missing container name inside "${GLOBAL_FUNC}()"` ), UNEXPECTED_DEFINITION: createDiagnosticReporter( '20008', 'error', (def: string) => `Unexpected value in container definition: "${def}""` ), }; interface ParsedNames { containers: Array<{ name: string; global: boolean }>; transformNames: (getTransformedName: (name: string) => string) => string; } const dataKey = plugableRecord.key<{ 'container-name': Record; container: Record; definitions: Record; }>('container'); // HOOKS STImport.ImportTypeHook.set(`container`, (context, localName, importName, importDef) => { addContainer({ context, name: localName, importName, ast: importDef.rule, global: false, importDef, forceDefinition: true, }); }); interface ResolvedSymbols { record: Record; locals: Set; } export const hooks = createFeature<{ RESOLVED: ResolvedSymbols; }>({ metaInit({ meta }) { plugableRecord.set(meta.data, dataKey, { 'container-name': {}, container: {}, definitions: {}, }); }, analyzeDeclaration({ context, decl }) { const prop = decl.prop.toLowerCase(); if (prop !== 'container-name' && prop !== 'container') { return; } const analyzed = plugableRecord.getUnsafe(context.meta.data, dataKey); const bucket = analyzed[prop]; const value = decl.value; if (bucket[value]) { return; } const parsed = (bucket[value] = parseContainerDecl(decl, context)); for (const { name, global } of parsed.containers) { addContainer({ context, ast: decl, name, importName: name, global, forceDefinition: false, }); } }, analyzeAtRule({ context, atRule }) { const ast = valueParser(atRule.params).nodes; let name = ''; let global = false; let searchForContainerName = true; let searchForLogicalOp = false; for (const node of ast) { if (node.type === 'comment' || node.type === 'space') { // do nothing continue; } else if (searchForContainerName && node.type === 'word') { searchForContainerName = false; searchForLogicalOp = true; name = node.value; } else if ( searchForContainerName && node.type === 'function' && node.value === GLOBAL_FUNC ) { searchForContainerName = false; searchForLogicalOp = true; name = globalValueFromFunctionNode(node) || ''; global = true; } else if (node.type === 'function' && node.value === 'style') { searchForContainerName = false; searchForLogicalOp = true; // check for custom properties for (const queryNode of node.nodes) { if (queryNode.type === 'word' && queryNode.value.startsWith('--')) { CSSCustomProperty.addCSSProperty({ context, node: atRule, name: queryNode.value, global: context.meta.type === 'css', final: false, }); } } } else if ( node.type !== 'function' && (!searchForLogicalOp || (node.type === 'word' && !logicalOpNames[node.value])) ) { const def = valueParser.stringify(node); context.diagnostics.report(diagnostics.UNEXPECTED_DEFINITION(def), { node: atRule, word: def, }); break; } } if (name && !atRule.nodes) { // treat @container with no body as definition if (invalidContainerNames[name]) { context.diagnostics.report(diagnostics.INVALID_CONTAINER_NAME(name), { node: atRule, word: name, }); } addContainer({ context, ast: atRule, name, importName: name, global, forceDefinition: true, }); } }, transformResolve({ context }) { const symbols = STSymbol.getAllByType(context.meta, `container`); const resolved: ResolvedSymbols = { record: {}, locals: new Set(), }; const resolvedSymbols = context.getResolvedSymbols(context.meta); for (const [name, symbol] of Object.entries(symbols)) { const res = resolvedSymbols.container[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_CONTAINER(symbol.name, symbol.import.request), { node: symbol.import.rule, word: symbol.name, } ); } } return resolved; }, transformDeclaration({ context, decl, resolved }) { const prop = decl.prop.toLowerCase(); if (prop !== 'container-name' && prop !== 'container') { return; } const analyzed = plugableRecord.getUnsafe(context.meta.data, dataKey); const bucket = analyzed[prop]; const value = decl.value; decl.value = bucket[value].transformNames((name) => { const resolve = resolved.record[name]; return resolve ? getTransformedName(resolved.record[name]) : name; }); }, transformAtRuleNode({ context, atRule, resolved }) { if (!atRule.nodes) { // remove definition only @container atRule.remove(); return; } const ast = valueParser(atRule.params).nodes; let changed = false; let searchForContainerName = true; search: for (const node of ast) { if (node.type === 'comment' || node.type === 'space') { // do nothing } else if (node.type === 'word' && searchForContainerName) { const resolve = resolved.record[node.value]; if (resolve) { node.value = getTransformedName(resolve); changed = true; } else { context.diagnostics.report(diagnostics.UNRESOLVED_CONTAINER_NAME(node.value), { node: atRule, word: node.value, }); } break search; } else if ( node.type === 'function' && node.value === GLOBAL_FUNC && searchForContainerName ) { const globalName = globalValueFromFunctionNode(node) || ''; if (globalName) { changed = true; const wordNode: WordNode = node as any; wordNode.type = 'word'; wordNode.value = globalName; } } else if (node.type === 'function' && node.value === 'style') { // check for custom properties searchForContainerName = false; for (const queryNode of node.nodes) { if (queryNode.type === 'word' && queryNode.value.startsWith('--')) { changed = true; CSSCustomProperty.transformPropertyIdent( context.meta, queryNode, context.getResolvedSymbols ); } } } } if (changed) { atRule.params = valueParser.stringify(ast); } atRule.params = context.evaluator.evaluateValue(context, { value: atRule.params, meta: context.meta, node: atRule, initialNode: atRule, }).outputValue; }, transformJSExports({ exports, resolved }) { for (const name of resolved.locals) { exports.containers[name] = getTransformedName(resolved.record[name]); } }, }); const invalidContainerNames: Record = { none: true, and: true, not: true, or: true, }; const logicalOpNames: Record = { and: true, not: true, or: true, }; function parseContainerDecl(decl: postcss.Declaration, context: FeatureContext): ParsedNames { const { prop, value } = decl; const containers: Array<{ name: string; global: boolean }> = []; const namedNodeRefs: Record = {}; const ast = valueParser(value).nodes; let noneFound = false; const checkNextName = (node: valueParser.Node) => { const { type, value } = node; if (type === 'comment' || type === 'space') { // do nothing } else if (type === 'word' || (type === 'function' && value === GLOBAL_FUNC)) { const global = type === 'function'; const name = global ? globalValueFromFunctionNode(node) || '' : node.value; if (global && !name) { context.diagnostics.report(diagnostics.MISSING_CONTAINER_NAME_INSIDE_GLOBAL(), { node: decl, }); } if (name === 'none') { noneFound = true; return; } if (!global) { containers.push({ name, global }); namedNodeRefs[name] ??= []; namedNodeRefs[name].push(node); } else { // mutate to word - this is safe since this node is not exposed (node as any).type = 'word'; (node as any).value = name; } if (invalidContainerNames[name]) { context.diagnostics.report(diagnostics.INVALID_CONTAINER_NAME(name), { node: decl, word: name, }); } } else { const word = valueParser.stringify(node); context.diagnostics.report(diagnostics.UNEXPECTED_DECL_VALUE(word), { node: decl, word, }); return false; } return true; }; if (prop.toLowerCase() === 'container-name') { for (const node of ast) { const continueParse = checkNextName(node); if (!continueParse) { break; } } } else { let nextExpected: 'name' | 'type' | '' = 'name'; for (const node of ast) { const { type, value } = node; if (type === 'comment' || type === 'space') { // do nothing } else if (nextExpected === 'name') { if (type === 'div' && value === '/') { nextExpected = 'type'; } else { const continueParse = checkNextName(node); if (!continueParse) { break; } } } else if (type === 'word' && nextExpected === 'type') { if (value !== 'normal' && value !== 'size' && value !== 'inline-size') { context.diagnostics.report(diagnostics.UNKNOWN_DECL_TYPE(value), { node: decl, word: value, }); } nextExpected = ''; } else { const word = valueParser.stringify(node); context.diagnostics.report(diagnostics.UNEXPECTED_DECL_VALUE(word), { node: decl, word, }); } } if (nextExpected === 'type') { context.diagnostics.report(diagnostics.MISSING_DECL_TYPE(), { node: decl, }); } } if (containers.length > 0 && noneFound) { context.diagnostics.report(diagnostics.INVALID_CONTAINER_NAME('none'), { node: decl, word: 'none', }); } return { containers, transformNames(getTransformedName: (name: string) => string) { for (const [name, nodes] of Object.entries(namedNodeRefs)) { const transformedName = getTransformedName(name); for (const modifiedNode of nodes) { if (modifiedNode.type === 'function') { // mutate to word - this is safe since this node is not exposed (modifiedNode as any).type = 'word'; } modifiedNode.value = transformedName; } } return valueParser.stringify(ast); }, }; } // API export function get(meta: StylableMeta, name: string): ContainerSymbol | undefined { return STSymbol.get(meta, name, `container`); } export function getAll(meta: StylableMeta): Record { return STSymbol.getAllByType(meta, `container`); } export function getDefinition( meta: StylableMeta, name: string ): postcss.Declaration | postcss.AtRule | postcss.Rule | undefined { const { definitions } = plugableRecord.getUnsafe(meta.data, dataKey); return definitions[name]; } function getTransformedName({ symbol, meta }: ResolvedContainer) { return symbol.global ? symbol.alias : namespace(symbol.alias, meta.namespace); } function addContainer({ context, name, importName, ast, global, importDef, forceDefinition, }: { context: FeatureContext; name: string; importName: string; ast: postcss.Declaration | postcss.AtRule | postcss.Rule; global: boolean; importDef?: STImport.Imported; forceDefinition: boolean; }) { const { definitions } = plugableRecord.getUnsafe(context.meta.data, dataKey); const definedSymbol = STSymbol.get(context.meta, name, 'container'); const isFirst = !definedSymbol; if (forceDefinition || isFirst) { if (context.meta.type !== 'stylable') { global = true; } definitions[name] = ast; STSymbol.addSymbol({ context, node: ast, localName: name, symbol: { _kind: 'container', name: importName, alias: name, global, import: importDef, }, safeRedeclare: false, }); } }