import { createFeature, FeatureContext, FeatureTransformContext } from './feature'; import { generalDiagnostics } from './diagnostics'; import * as STSymbol from './st-symbol'; import { plugableRecord } from '../helpers/plugable-record'; import { parseStImport, parsePseudoImport, parseImportMessages, tryCollectImportsDeep, } from '../helpers/import'; import { validateCustomPropertyName } from '../helpers/css-custom-property'; import type { StylableMeta } from '../stylable-meta'; import path from 'path'; import type { ImmutablePseudoClass, PseudoClass } from '@tokey/css-selector-parser'; import type * as postcss from 'postcss'; import { createDiagnosticReporter } from '../diagnostics'; import type { Stylable } from '../stylable'; import type { CachedModuleEntity } from '../stylable-resolver'; export interface ImportSymbol { _kind: 'import'; type: 'named' | 'default'; name: string; import: Imported; context: string; } export interface AnalyzedImport { from: string; default: string; named: Record; typed: { keyframes: Record; }; } export interface Imported { from: string; defaultExport: string; named: Record; /**@deprecated use imported.typed.keyframes (remove in stylable 5) */ keyframes: Record; typed: Record>; rule: postcss.Rule | postcss.AtRule; request: string; context: string; } export const PseudoImport = `:import`; export const PseudoImportDecl = { DEFAULT: `-st-default`, NAMED: `-st-named`, FROM: `-st-from`, } as const; /** * ImportTypeHook is used as a way to cast imported symbols before resolving their actual type. * currently used only for `keyframes` as they are completely on a separate namespace from other symbols. * * Hooks are registered statically since the features are static and cannot be selected/disabled. * If the system will ever change to support picking features dynamically, this mechanism would * have to move into the `metaInit` hook. */ export const ImportTypeHook = new Map< string, (context: FeatureContext, localName: string, importName: string, importDef: Imported) => void >(); const dataKey = plugableRecord.key('imports'); export const diagnostics = { ...parseImportMessages, FORBIDDEN_DEF_IN_COMPLEX_SELECTOR: generalDiagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR, NO_ST_IMPORT_IN_NESTED_SCOPE: createDiagnosticReporter( '05011', 'error', () => `cannot use "@st-import" inside of nested scope` ), NO_PSEUDO_IMPORT_IN_NESTED_SCOPE: createDiagnosticReporter( '05012', 'error', () => `cannot use ":import" inside of nested scope` ), INVALID_CUSTOM_PROPERTY_AS_VALUE: createDiagnosticReporter( '05013', 'error', (name: string, as: string) => `invalid alias for custom property "${name}" as "${as}"; custom properties must be prefixed with "--" (double-dash)` ), UNKNOWN_IMPORTED_SYMBOL: createDiagnosticReporter( '05015', 'error', (name: string, path: string) => `cannot resolve imported symbol "${name}" from stylesheet "${path}"` ), UNKNOWN_IMPORTED_FILE: createDiagnosticReporter( '05016', 'error', (path: string, error?: unknown) => `cannot resolve imported file: "${path}"${error ? `\nFailed with:\n${error}` : ''}` ), UNKNOWN_TYPED_IMPORT: createDiagnosticReporter( '05018', 'error', (type: string) => `Unknown type import "${type}"` ), NO_DEFAULT_EXPORT: createDiagnosticReporter( '05020', 'error', (path: string) => `Native CSS files have no default export. Imported file: "${path}"` ), UNSUPPORTED_NATIVE_IMPORT: createDiagnosticReporter( '05021', 'warning', () => `Unsupported @import within imported native CSS file` ), }; // HOOKS export const hooks = createFeature<{ SELECTOR: PseudoClass; IMMUTABLE_SELECTOR: ImmutablePseudoClass; }>({ metaInit({ meta }) { plugableRecord.set(meta.data, dataKey, []); }, analyzeInit(context) { const imports = plugableRecord.getUnsafe(context.meta.data, dataKey); const dirContext = path.dirname(context.meta.source); // collect shallow imports for (const node of context.meta.sourceAst.nodes) { if (!isImportStatement(node)) { continue; } const parsedImport = node.type === `atrule` ? parseStImport(node, dirContext, context.diagnostics) : parsePseudoImport(node, dirContext, context.diagnostics); imports.push(parsedImport); addImportSymbols(parsedImport, context, dirContext); } }, analyzeAtRule({ context, atRule }) { if (atRule.name === `st-import` && atRule.parent?.type !== `root`) { context.diagnostics.report(diagnostics.NO_ST_IMPORT_IN_NESTED_SCOPE(), { node: atRule, }); } else if (atRule.name === `import` && context.meta.type === 'css') { context.diagnostics.report(diagnostics.UNSUPPORTED_NATIVE_IMPORT(), { node: atRule, }); } }, analyzeSelectorNode({ context, rule, node }) { if (node.value !== `import`) { return; } if (rule.selector !== `:import`) { context.diagnostics.report( diagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(PseudoImport), { node: rule } ); return; } if (rule.parent?.type !== `root`) { context.diagnostics.report(diagnostics.NO_PSEUDO_IMPORT_IN_NESTED_SCOPE(), { node: rule, }); } }, prepareAST({ node, toRemove }) { if (isImportStatement(node)) { toRemove.push(node); } }, transformInit({ context }) { validateImports(context); calcCssDepth(context); }, }); // API export class StylablePublicApi { constructor(private stylable: Stylable) {} public analyze(meta: StylableMeta): AnalyzedImport[] { return getImportStatements(meta).map(({ request, defaultExport, named, keyframes }) => ({ from: request, default: defaultExport, named, typed: { keyframes, }, })); } } function calcCssDepth(context: FeatureTransformContext) { let cssDepth = 1; const deepDependencies = tryCollectImportsDeep( context.resolver, context.meta, new Set(), ({ depth, request }) => { if (request.endsWith('.css')) { cssDepth = Math.max(cssDepth, depth); } }, 2 ); context.meta.transformCssDepth = { cssDepth, deepDependencies }; } function isImportStatement(node: postcss.ChildNode): node is postcss.Rule | postcss.AtRule { return ( (node.type === `atrule` && node.name === `st-import`) || (node.type === `rule` && node.selector === `:import`) ); } export function getImportStatements({ data }: StylableMeta): ReadonlyArray { const state = plugableRecord.getUnsafe(data, dataKey); return state; } export function createImportSymbol( importDef: Imported, type: `default` | `named`, name: string, dirContext: string ): ImportSymbol { return { _kind: 'import', type: type === 'default' ? `default` : `named`, name: type === `default` ? name : importDef.named[name], import: importDef, context: dirContext, }; } // internal function addImportSymbols(importDef: Imported, context: FeatureContext, dirContext: string) { checkForInvalidAsUsage(importDef, context); if (importDef.defaultExport) { STSymbol.addSymbol({ context, localName: importDef.defaultExport, symbol: createImportSymbol(importDef, `default`, `default`, dirContext), node: importDef.rule, }); } Object.keys(importDef.named).forEach((name) => { STSymbol.addSymbol({ context, localName: name, symbol: createImportSymbol(importDef, `named`, name, dirContext), node: importDef.rule, }); }); // import as typed symbol for (const [type, imports] of Object.entries(importDef.typed)) { const handler = ImportTypeHook.get(type); if (handler) { for (const [localName, importName] of Object.entries(imports)) { handler(context, localName, importName, importDef); } } else { context.diagnostics.report(diagnostics.UNKNOWN_TYPED_IMPORT(type), { node: importDef.rule, word: type, }); } } } function checkForInvalidAsUsage(importDef: Imported, context: FeatureContext) { for (const [local, imported] of Object.entries(importDef.named)) { if (validateCustomPropertyName(imported) && !validateCustomPropertyName(local)) { context.diagnostics.report( diagnostics.INVALID_CUSTOM_PROPERTY_AS_VALUE(imported, local), { node: importDef.rule } ); } } } function validateImports(context: FeatureTransformContext) { const imports = plugableRecord.getUnsafe(context.meta.data, dataKey); for (const importObj of imports) { const entity = context.resolver.getModule(importObj); if (!entity.value) { // warn about unknown imported files const fromDecl = importObj.rule.nodes && importObj.rule.nodes.find( (decl) => decl.type === 'decl' && decl.prop === PseudoImportDecl.FROM ); context.diagnostics.report( diagnostics.UNKNOWN_IMPORTED_FILE(importObj.request, getErrorText(entity)), { node: fromDecl || importObj.rule, word: importObj.request, } ); } else if (entity.kind === 'css') { const meta = entity.value; // propagate some native CSS diagnostics to st-import if (meta.type === 'css') { let foundUnsupportedNativeImport = false; for (const report of meta.diagnostics.reports) { if (report.code === '05021') { foundUnsupportedNativeImport = true; break; } } if (foundUnsupportedNativeImport) { context.diagnostics.report(diagnostics.UNSUPPORTED_NATIVE_IMPORT(), { node: importObj.rule, word: importObj.defaultExport, }); } } // report unsupported native CSS default import if (meta.type !== 'stylable' && importObj.defaultExport) { context.diagnostics.report(diagnostics.NO_DEFAULT_EXPORT(importObj.request), { node: importObj.rule, word: importObj.defaultExport, }); } // warn about unknown named imported symbols for (const name in importObj.named) { const origName = importObj.named[name]; const resolvedSymbol = context.resolver.resolveImported(importObj, origName); if (resolvedSymbol === null || !resolvedSymbol.symbol) { const namedDecl = importObj.rule.nodes && importObj.rule.nodes.find( (decl) => decl.type === 'decl' && decl.prop === PseudoImportDecl.NAMED ); context.diagnostics.report( diagnostics.UNKNOWN_IMPORTED_SYMBOL(origName, importObj.request), { node: namedDecl || importObj.rule, word: origName } ); } } } else if (entity.kind === 'js') { // TODO: add diagnostics for JS imports (typeof checks) } } } function getErrorText(res: CachedModuleEntity) { if ('error' in res) { const { error } = res; if (typeof error === 'object' && error) { return 'details' in error ? String(error.details) : 'message' in error ? String(error.message) : String(error); } return String(error); } return ''; }