import path from 'path'; import { parseImports } from '@tokey/imports-parser'; import { createDiagnosticReporter, Diagnostics } from '../diagnostics'; import type { Imported } from '../features'; import { Root, decl, Declaration, atRule, rule, Rule, AtRule } from 'postcss'; import { stripQuotation } from '../helpers/string'; import { isCompRoot } from './selector'; import type { ParsedValue } from '../types'; import type { StylableMeta } from '../stylable-meta'; import type * as postcss from 'postcss'; import postcssValueParser, { ParsedValue as PostCSSParsedValue, FunctionNode, } from 'postcss-value-parser'; import type { StylableResolver } from '../stylable-resolver'; export const parseImportMessages = { ST_IMPORT_STAR: createDiagnosticReporter( '05001', 'error', () => '@st-import * is not supported' ), INVALID_ST_IMPORT_FORMAT: createDiagnosticReporter( '05002', 'error', (errors: string[]) => `Invalid @st-import format:\n - ${errors.join('\n - ')}` ), ST_IMPORT_EMPTY_FROM: createDiagnosticReporter( '05003', 'error', () => '@st-import must specify a valid "from" string value' ), EMPTY_IMPORT_FROM: createDiagnosticReporter( '05004', 'error', () => '"-st-from" cannot be empty' ), MULTIPLE_FROM_IN_IMPORT: createDiagnosticReporter( '05005', 'warning', () => `cannot define multiple "-st-from" declarations in a single import` ), DEFAULT_IMPORT_IS_LOWER_CASE: createDiagnosticReporter( '05006', 'warning', () => 'Default import of a Stylable stylesheet must start with an upper-case letter' ), ILLEGAL_PROP_IN_IMPORT: createDiagnosticReporter( '05007', 'warning', (propName: string) => `"${propName}" css attribute cannot be used inside :import block` ), FROM_PROP_MISSING_IN_IMPORT: createDiagnosticReporter( '05008', 'error', () => `"-st-from" is missing in :import block` ), INVALID_NAMED_IMPORT_AS: createDiagnosticReporter( '05009', 'error', (name: string) => `Invalid named import "as" with name "${name}"` ), INVALID_NESTED_KEYFRAMES: createDiagnosticReporter( '05010', 'error', (name: string) => `Invalid nested keyframes import "${name}"` ), INVALID_NESTED_TYPED_IMPORT: createDiagnosticReporter( '05019', 'warning', (type: string, name: string) => `Invalid nested ${type} import "${name}"` ), }; export const ensureImportsMessages = { ATTEMPT_OVERRIDE_SYMBOL: createDiagnosticReporter( '16001', 'error', (kind: 'default' | 'named' | 'keyframes', origin: string, override: string) => `Attempt to override existing ${kind} import symbol. ${origin} -> ${override}` ), PATCH_CONTAINS_NEW_IMPORT_IN_NEW_IMPORT_NONE_MODE: createDiagnosticReporter( '16002', 'error', () => `Attempt to insert new a import in newImport "none" mode` ), }; export function createAtImportProps( importObj: Partial> ): { name: string; params: string; } { const named = Object.entries(importObj.named || {}); const keyframes = Object.entries(importObj.keyframes || {}); let params = ''; if (importObj.defaultExport) { params += importObj.defaultExport; } if (importObj.defaultExport && (named.length || keyframes.length)) { params += ', '; } if (named.length || keyframes.length) { params += '['; const namedParts = getNamedImportParts(named); const keyFramesParts = getNamedImportParts(keyframes); params += namedParts.join(', '); if (keyFramesParts.length) { if (namedParts.length) { params += ', '; } params += `keyframes(${keyFramesParts.join(', ')})`; } params += ']'; } params += ` from ${JSON.stringify(importObj.request || '')}`; return { name: 'st-import', params }; } export function ensureModuleImport( ast: Root, importPatches: Array, options: { newImport: 'none' | 'st-import' | ':import'; }, diagnostics: Diagnostics = new Diagnostics() ) { const patches = createImportPatches(ast, importPatches, options, diagnostics); if (!diagnostics.reports.length) { for (const patch of patches) { patch(); } } return { diagnostics }; } function createImportPatches( ast: Root, importPatches: Array, { newImport }: { newImport: 'none' | 'st-import' | ':import' }, diagnostics: Diagnostics ) { const patches: Array<() => void> = []; const handled = new Set(); for (const node of ast.nodes) { if (node.type === 'atrule' && node.name === 'st-import') { const imported = parseStImport(node, '*', diagnostics); processImports(imported, importPatches, handled, diagnostics); patches.push(() => node.assign(createAtImportProps(imported))); } else if (node.type === 'rule' && node.selector === ':import') { const imported = parsePseudoImport(node, '*', diagnostics); processImports(imported, importPatches, handled, diagnostics); patches.push(() => { const named = generateNamedValue(imported); const { defaultDecls, namedDecls } = patchDecls(node, named, imported); if (imported.defaultExport) { ensureSingleDecl(defaultDecls, node, '-st-default', imported.defaultExport); } if (named.length) { ensureSingleDecl(namedDecls, node, '-st-named', named.join(', ')); } }); } } if (newImport === 'none') { if (handled.size !== importPatches.length) { diagnostics.report( ensureImportsMessages.PATCH_CONTAINS_NEW_IMPORT_IN_NEW_IMPORT_NONE_MODE(), { node: ast } ); } return patches; } if (handled.size === importPatches.length) { return patches; } for (const item of importPatches) { if (handled.has(item)) { continue; } if (!hasDefinitions(item)) { continue; } if (newImport === 'st-import') { patches.push(() => { ast.prepend( atRule( createAtImportProps({ defaultExport: item.defaultExport || '', keyframes: item.keyframes || {}, named: item.named || {}, request: item.request, }) ) ); }); } else { patches.push(() => { ast.prepend(rule(createPseudoImportProps(item))); }); } } return patches; } function setImportObjectFrom(importPath: string, dirPath: string, importObj: Imported) { if (!path.isAbsolute(importPath) && !importPath.startsWith('.')) { importObj.request = importPath; importObj.from = importPath; } else { importObj.request = importPath; importObj.from = path.posix && path.posix.isAbsolute(dirPath) // browser has no posix methods ? path.posix.resolve(dirPath, importPath) : path.resolve(dirPath, importPath); } } export function parseModuleImportStatement( node: AtRule | Rule, context: string, diagnostics: Diagnostics ) { if (node.type === 'atrule') { return parseStImport(node, context, diagnostics); } else { return parsePseudoImport(node, context, diagnostics); } } export function parseStImport(atRule: AtRule, context: string, diagnostics: Diagnostics) { const keyframes = {}; const importObj: Imported = { defaultExport: '', from: '', request: '', named: {}, rule: atRule, context, keyframes, typed: { keyframes, }, }; const imports = parseImports(`import ${atRule.params}`, '[', ']', true)[0]; if (imports && imports.star) { diagnostics.report(parseImportMessages.ST_IMPORT_STAR(), { node: atRule }); } else { setImportObjectFrom(imports.from || '', context, importObj); importObj.defaultExport = imports.defaultName || ''; if ( importObj.defaultExport && !isCompRoot(importObj.defaultExport) && importObj.from.endsWith(`.css`) ) { diagnostics.report(parseImportMessages.DEFAULT_IMPORT_IS_LOWER_CASE(), { node: atRule, word: importObj.defaultExport, }); } if (imports.tagged) { for (const [kind, namedTyped] of Object.entries(imports.tagged)) { if (!namedTyped) { continue; } for (const [impName, impAsName] of namedTyped) { importObj.typed[kind] ??= {}; importObj.typed[kind][impAsName] = impName; } } } if (imports.named) { for (const [impName, impAsName] of imports.named) { importObj.named[impAsName] = impName; } } if (imports.errors.length) { diagnostics.report(parseImportMessages.INVALID_ST_IMPORT_FORMAT(imports.errors), { node: atRule, }); } else if (!imports.from?.trim()) { diagnostics.report(parseImportMessages.ST_IMPORT_EMPTY_FROM(), { node: atRule }); } } return importObj; } export function parsePseudoImport(rule: Rule, context: string, diagnostics: Diagnostics) { let fromExists = false; const keyframes = {}; const importObj: Imported = { defaultExport: '', from: '', request: '', named: {}, keyframes, typed: { keyframes, }, rule, context, }; rule.walkDecls((decl) => { switch (decl.prop) { case `-st-from`: { const importPath = stripQuotation(decl.value); if (!importPath.trim()) { diagnostics.report(parseImportMessages.EMPTY_IMPORT_FROM(), { node: decl }); } if (fromExists) { diagnostics.report(parseImportMessages.MULTIPLE_FROM_IN_IMPORT(), { node: rule, }); } setImportObjectFrom(importPath, context, importObj); fromExists = true; break; } case `-st-default`: importObj.defaultExport = decl.value; if (!isCompRoot(importObj.defaultExport) && importObj.from.endsWith(`.css`)) { diagnostics.report(parseImportMessages.DEFAULT_IMPORT_IS_LOWER_CASE(), { node: decl, word: importObj.defaultExport, }); } break; case `-st-named`: { const { typedMap, namedMap } = parsePseudoImportNamed( decl.value, decl, diagnostics ); importObj.named = namedMap; importObj.keyframes = typedMap.keyframes || {}; importObj.typed = typedMap; } break; default: diagnostics.report(parseImportMessages.ILLEGAL_PROP_IN_IMPORT(decl.prop), { node: decl, word: decl.prop, }); break; } }); if (!importObj.from) { diagnostics.report(parseImportMessages.FROM_PROP_MISSING_IN_IMPORT(), { node: rule, }); } return importObj; } export function parsePseudoImportNamed( value: string, node: postcss.Declaration | postcss.AtRule, diagnostics: Diagnostics ) { const namedMap: Record = {}; const typedMap: Record> = {}; if (value) { handleNamedTokens(postcssValueParser(value), namedMap, typedMap, node, diagnostics); } return { namedMap, typedMap }; } function createPseudoImportProps( item: Partial> ) { const nodes = []; const named = generateNamedValue(item); if (item.request) { nodes.push(decl({ prop: '-st-from', value: JSON.stringify(item.request) })); } if (item.defaultExport) { nodes.push( decl({ prop: '-st-default', value: item.defaultExport, }) ); } if (named.length) { nodes.push( decl({ prop: '-st-named', value: named.join(', '), }) ); } return { selector: ':import', nodes, }; } function patchDecls(node: Rule, named: string[], pseudoImport: Imported) { const namedDecls: Declaration[] = []; const defaultDecls: Declaration[] = []; for (const decl of node.nodes) { if (decl.type !== 'decl') { continue; } if (decl.prop === '-st-named') { decl.assign({ value: named.join(', ') }); namedDecls.push(decl); } else if (decl.prop === '-st-default') { decl.assign({ value: pseudoImport.defaultExport }); defaultDecls.push(decl); } } return { defaultDecls, namedDecls }; } function ensureSingleDecl(decls: Declaration[], node: Rule, prop: string, value: string) { if (!decls.length) { node.append(decl({ prop, value })); } else if (decls.length > 1) { // remove duplicates keep last one for (let i = 0; i < decls.length - 1; i++) { decls[i].remove(); } } } function getNamedImportParts(named: [string, string][]) { const parts: string[] = []; for (const [as, name] of named) { if (as === name) { parts.push(name); } else { parts.push(`${name} as ${as}`); } } return parts; } type ImportPatch = Partial> & Pick; function generateNamedValue({ named = {}, keyframes = {}, }: Partial>) { const namedParts = getNamedImportParts(Object.entries(named)); const keyframesParts = getNamedImportParts(Object.entries(keyframes)); if (keyframesParts.length) { namedParts.push(`keyframes(${keyframesParts.join(', ')})`); } return namedParts; } function hasDefinitions({ named = {}, keyframes = {}, defaultExport, }: Partial>) { return defaultExport || Object.keys(named).length || Object.keys(keyframes).length; } function processImports( imported: Imported, importPatches: Array, handled: Set, diagnostics: Diagnostics ) { const ops = ['named', 'keyframes'] as const; for (const patch of importPatches) { if (handled.has(patch)) { continue; } if (imported.request === patch.request) { for (const op of ops) { const patchBucket = patch[op]; if (!patchBucket) { continue; } for (const [asName, symbol] of Object.entries(patchBucket)) { const currentSymbol = imported[op][asName]; if (currentSymbol === symbol) { continue; } else if (currentSymbol) { diagnostics.report( ensureImportsMessages.ATTEMPT_OVERRIDE_SYMBOL( op, currentSymbol === asName ? currentSymbol : `${currentSymbol} as ${asName}`, symbol === asName ? symbol : `${symbol} as ${asName}` ), { node: imported.rule, } ); } else { imported[op][asName] = symbol; } } } if (patch.defaultExport) { if (!imported.defaultExport) { imported.defaultExport = patch.defaultExport; } else if (imported.defaultExport !== patch.defaultExport) { diagnostics.report( ensureImportsMessages.ATTEMPT_OVERRIDE_SYMBOL( 'default', imported.defaultExport, patch.defaultExport ), { node: imported.rule, } ); } } handled.add(patch); } } } function handleNamedTokens( tokens: PostCSSParsedValue | FunctionNode, mainBucket: Record, typedBuckets: Record> | null, node: postcss.Declaration | postcss.AtRule, diagnostics: Diagnostics ) { const { nodes } = tokens; for (let i = 0; i < nodes.length; i++) { const token = nodes[i]; if (token.type === 'word') { const space = nodes[i + 1]; const as = nodes[i + 2]; const spaceAfter = nodes[i + 3]; const asName = nodes[i + 4]; if (isImportAs(space, as)) { if (spaceAfter?.type === 'space' && asName?.type === 'word') { mainBucket[asName.value] = token.value; i += 4; //ignore next 4 tokens } else { i += !asName ? 3 : 2; diagnostics.report(parseImportMessages.INVALID_NAMED_IMPORT_AS(token.value), { node, }); continue; } } else { mainBucket[token.value] = token.value; } } else if (token.type === 'function') { if (!typedBuckets) { diagnostics.report( parseImportMessages.INVALID_NESTED_TYPED_IMPORT( token.value, postcssValueParser.stringify(token) ), { node } ); } else { typedBuckets[token.value] ??= {}; handleNamedTokens(token, typedBuckets[token.value], null, node, diagnostics); } } } } function isImportAs(space: ParsedValue, as: ParsedValue) { return space?.type === 'space' && as?.type === 'word' && as?.value === 'as'; } type ImportEvent = { context: string; request: string; resolved: string; depth: number; }; export function tryCollectImportsDeep( resolver: StylableResolver, meta: StylableMeta, imports = new Set(), onImport: undefined | ((e: ImportEvent) => void) = undefined, depth = 1, origin = meta.source ) { for (const { context, request } of meta.getImportStatements()) { try { const resolved = resolver.resolvePath(context, request); if (resolved === origin) { continue; } onImport?.({ context, request, resolved, depth }); if (!imports.has(resolved)) { imports.add(resolved); if (resolved.endsWith('.st.css')) { tryCollectImportsDeep( resolver, resolver.analyze(resolved), imports, onImport, depth + 1, origin ); } } } catch { /** */ } } return imports; }