diff --git a/README.md b/README.md index 2602c5f..44c665a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ ecij({ }); ``` +## Limitations + +- The `css` tag must be imported directly from `'ecij'` (aliasing it is fine, + e.g. `import { css as styled } from 'ecij'`). Re-exporting the tag through + another module is not supported and leaves the templates untransformed. +- Interpolations must statically resolve to strings or numbers; dynamic or + complex expressions cause the css`` block to be skipped with a warning. + ## Development ### Building @@ -167,5 +175,4 @@ npm test -- -u ## TODO -- Full import/export handling (default/namespace import/export) - Sourcemaps diff --git a/src/index.ts b/src/index.ts index 175b808..416943e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { cwd } from 'node:process'; import type { BindingPattern, BlockStatement, + Expression, ParamPattern, TaggedTemplateExpression, } from '@oxc-project/types'; @@ -50,10 +51,32 @@ interface Declaration { scope: Scope; } +type ImportedIdentifier = + // `import { x } from 'mod'` (`imported` is `'default'` for default imports) + | { kind: 'named'; source: string; imported: string } + // `import * as ns from 'mod'` + | { kind: 'namespace'; source: string }; + +type ExportRecord = + // Locally-resolved literal/css class. `fromCss` marks css`` class names, + // which are only usable once their declaration was actually extracted. + // `localName` identifies the underlying binding (per ECMA-262, two export + // aliases of the same binding are NOT ambiguous when reached via `export *`). + | { kind: 'value'; value: string; fromCss: boolean; localName: string | undefined } + // `export { x } from 'mod'`, including default-as-name and name-as-default + | { kind: 'reexport'; source: string; imported: string } + // `export * as ns from 'mod'` + | { kind: 'namespace-reexport'; source: string } + // Explicit export whose value is not statically known. Recorded so explicit + // exports still shadow `export *` sources, per ESM precedence. + | { kind: 'unresolved'; localName: string | undefined }; + interface ParsedFileInfo { readonly declarations: readonly Declaration[]; - readonly importedIdentifiers: ReadonlyMap; - readonly exportNameToValueMap: ReadonlyMap; + readonly importedIdentifiers: ReadonlyMap; + readonly exportNameToValueMap: ReadonlyMap; + // Sources from `export * from 'mod'` (looked up when a name is missing from exportNameToValueMap) + readonly exportStarSources: readonly string[]; } // allow .js, .cjs, .mjs, .ts, .cts, .mts, .jsx, .tsx files @@ -70,6 +93,78 @@ function hashText(text: string): string { return createHash('md5').update(text).digest('hex').slice(0, 8); } +// Remove query parameters from a module ID, e.g. `/src/a.ts?used` -> `/src/a.ts` +function stripQuery(id: string): string { + const queryIndex = id.indexOf('?'); + return queryIndex === -1 ? id : id.slice(0, queryIndex); +} + +// Unwraps parentheses and TypeScript type-assertion wrappers, +// which do not change the runtime value of an expression. +function unwrapExpression(expression: Expression): Expression { + while ( + expression.type === 'ParenthesizedExpression' || + expression.type === 'TSAsExpression' || + expression.type === 'TSSatisfiesExpression' || + expression.type === 'TSNonNullExpression' + ) { + expression = expression.expression; + } + + return expression; +} + +// Statically evaluates an expression to its string value: +// string/number literals and signed number literals (`-5`, `+5`). +function resolveStaticExpression(expression: Expression): string | undefined { + expression = unwrapExpression(expression); + + switch (expression.type) { + case 'Literal': + if (typeof expression.value === 'string' || typeof expression.value === 'number') { + return String(expression.value); + } + break; + + case 'UnaryExpression': { + const argument = unwrapExpression(expression.argument); + if ( + (expression.operator === '-' || expression.operator === '+') && + argument.type === 'Literal' && + typeof argument.value === 'number' + ) { + return String(expression.operator === '-' ? -argument.value : argument.value); + } + break; + } + } + + return undefined; +} + +// Flattens `ns.inner.foo` into `['ns', 'inner', 'foo']`. Returns undefined +// for anything other than a plain (non-computed, non-optional) identifier +// chain; parens/TS assertion wrappers around any segment are unwrapped. +function flattenMemberExpressionPath(expression: Expression): string[] | undefined { + const names: string[] = []; + let current = unwrapExpression(expression); + + while (current.type === 'MemberExpression') { + if (current.computed || current.optional || current.property.type !== 'Identifier') { + return undefined; + } + names.unshift(current.property.name); + current = unwrapExpression(current.object); + } + + if (current.type !== 'Identifier') { + return undefined; + } + + names.unshift(current.name); + return names; +} + export function ecij(configuration?: Configuration | undefined | null): Plugin { const include = configuration?.include ?? JS_TS_FILE_REGEX; const exclude = configuration?.exclude ?? [NODE_MODULES_REGEX, D_TS_FILE_REGEX]; @@ -77,6 +172,39 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { const parsedFileInfoCache = new Map(); + // Class names that were actually extracted, per file. A css`` class name is + // only safe to inline elsewhere once its rule made it into a stylesheet. + const extractedClassesPerFile = new Map>(); + + // Which module load each in-flight transform is currently awaiting + // (transform id -> awaited module ids, reference-counted). Used to detect + // when awaiting a `context.load` would close a wait cycle and deadlock the + // build (a load only settles once the module's transform returns). + const pendingLoads = new Map>(); + + function addPendingLoad(from: string, to: string) { + let targets = pendingLoads.get(from); + if (targets === undefined) { + targets = new Map(); + pendingLoads.set(from, targets); + } + targets.set(to, (targets.get(to) ?? 0) + 1); + } + + function removePendingLoad(from: string, to: string) { + const targets = pendingLoads.get(from); + const count = targets?.get(to); + if (targets === undefined || count === undefined) return; + if (count > 1) { + targets.set(to, count - 1); + } else { + targets.delete(to); + if (targets.size === 0) { + pendingLoads.delete(from); + } + } + } + // Map to store generated CSS module IDs for each source file, used to mark modules as having side effects // Key: module id, Value: CSS module id const stylesheetImportPerFile = new Map(); @@ -105,16 +233,34 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { // to ensure consistent hashes across different build environments (Windows/Unix) const relativePath = relative(PROJECT_ROOT, filePath).replaceAll('\\', '/'); - // Read the source file - const sourceText = code ?? (await context.fs.readFile(filePath, { encoding: 'utf8' })); + // Prefer the module graph's copy of the source. Note `ModuleInfo.code` is + // the *post-transform* code — acceptable here because only exports/values + // are read from these parses (positions are never used across files), and + // it also covers virtual modules that have no file on disk. Fall back to + // the disk for modules that haven't entered the graph yet; an unreadable + // module (e.g. virtual id) parses as empty, degrading to an + // UNRESOLVED_INTERPOLATION warning at the consumer. + const sourceText = + code ?? + context.getModuleInfo(filePath)?.code ?? + (await context.fs.readFile(filePath, { encoding: 'utf8' }).catch(() => '')); const parseResult = parseSync(filePath, sourceText); const declarations: Declaration[] = []; - const importedIdentifiers = new Map(); - const exportNameToValueMap = new Map(); - const localNameToExportedNameMap = new Map(); - const taggedTemplateExpressionFromVariableDeclarator = new Set(); - let hasCSSTagImport = false; + const importedIdentifiers = new Map(); + const exportNameToValueMap = new Map(); + const exportStarSources: string[] = []; + // Multiple exported names can reference the same local binding + // (e.g. `export { foo, foo as bar }`). + const localNameToExportedNamesMap = new Map(); + const processedTaggedTemplateExpressions = new Set(); + // Local bindings of `css` imported from 'ecij' (including aliases) + const cssTagNames = new Set(); + // Spans of default-import local bindings: oxc normalizes + // `import foo from 'mod'; export { foo };` into a re-export entry whose + // importName is the *local* binding (carrying its span) instead of + // 'default' — these spans let us map such entries back to the default. + const defaultImportLocalSpans = new Set(); // Scope tracking: root scope for module-level declarations const rootScope: Scope = { identifiers: new Map(), parent: null, isFunctionScope: true }; @@ -125,24 +271,44 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { declarations, importedIdentifiers, exportNameToValueMap, + exportStarSources, }; parsedFileInfoCache.set(filePath, parsedInfo); // Collect imports for (const staticImport of parseResult.module.staticImports) { + const source = staticImport.moduleRequest.value; + for (const entry of staticImport.entries) { - // TODO: support default and namespace imports - if (entry.importName.kind === 'Name') { - const source = staticImport.moduleRequest.value; - const imported = entry.importName.name!; - const localName = entry.localName.value; - - if (source === 'ecij' && imported === 'css' && localName === 'css') { - hasCSSTagImport = true; - } + // Skip TypeScript type-only imports + if (entry.isType) continue; + + const localName = entry.localName.value; - importedIdentifiers.set(localName, { source, imported }); + switch (entry.importName.kind) { + case 'Name': { + // `import { foo } from 'mod'` / `import { foo as bar } from 'mod'` + const imported = entry.importName.name!; + + if (source === 'ecij' && imported === 'css') { + cssTagNames.add(localName); + } + + importedIdentifiers.set(localName, { kind: 'named', source, imported }); + break; + } + case 'Default': { + // `import foo from 'mod'` + defaultImportLocalSpans.add(`${entry.localName.start}:${entry.localName.end}`); + importedIdentifiers.set(localName, { kind: 'named', source, imported: 'default' }); + break; + } + case 'NamespaceObject': { + // `import * as ns from 'mod'` + importedIdentifiers.set(localName, { kind: 'namespace', source }); + break; + } } } } @@ -150,34 +316,135 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { // Collect exports for (const staticExport of parseResult.module.staticExports) { for (const entry of staticExport.entries) { - // TODO: handle re-exports - if (entry.importName.kind !== 'None') continue; + // Skip TypeScript type-only exports + if (entry.isType) continue; + + const moduleRequest = entry.moduleRequest?.value; + + // Re-exports (have a moduleRequest) + if (moduleRequest !== undefined) { + switch (entry.importName.kind) { + case 'Name': { + // `export { foo } from 'mod'`, `export { foo as bar } from 'mod'`, + // `export { default as foo } from 'mod'`, `export { foo as default } from 'mod'` + const exportedName = + entry.exportName.kind === 'Default' ? 'default' : entry.exportName.name!; + // `import foo from 'mod'; export { foo };` entries point at the + // local default-import binding — map them back to 'default' + // (see `defaultImportLocalSpans`). + const imported = defaultImportLocalSpans.has( + `${entry.importName.start}:${entry.importName.end}`, + ) + ? 'default' + : entry.importName.name!; + exportNameToValueMap.set(exportedName, { + kind: 'reexport', + source: moduleRequest, + imported, + }); + break; + } + case 'AllButDefault': { + // `export * from 'mod'` — does not include the default export + exportStarSources.push(moduleRequest); + break; + } + case 'All': { + // `export * as ns from 'mod'` + const exportedName = entry.exportName.name!; + exportNameToValueMap.set(exportedName, { + kind: 'namespace-reexport', + source: moduleRequest, + }); + break; + } + } + continue; + } - // TODO: support default and namespace exports - if (entry.exportName.kind === 'Name' && entry.localName.kind === 'Name') { - const localName = entry.localName.name!; - const exportedName = entry.exportName.name!; - localNameToExportedNameMap.set(localName, exportedName); + // Local exports (no moduleRequest) + // `localName.kind === 'Default'` covers `export default `, + // `localName.kind === 'Name'` covers `export { x }` and `export { x as y }`. + if ( + entry.exportName.kind !== 'None' && + (entry.localName.kind === 'Name' || entry.localName.kind === 'Default') && + entry.localName.name !== null + ) { + const localName = entry.localName.name; + // `entry.exportName.name` is null when `kind === 'Default'` (the name "default" + // is implicit in `export default `). + const exportedName = + entry.exportName.kind === 'Default' ? 'default' : entry.exportName.name!; + + // If the local name is actually an imported identifier, the export is + // a transitive re-export (`import { foo } from 'mod'; export default foo;`). + const importEntry = importedIdentifiers.get(localName); + if (importEntry !== undefined) { + if (importEntry.kind === 'named') { + exportNameToValueMap.set(exportedName, { + kind: 'reexport', + source: importEntry.source, + imported: importEntry.imported, + }); + } else { + exportNameToValueMap.set(exportedName, { + kind: 'namespace-reexport', + source: importEntry.source, + }); + } + continue; + } + + // Map a locally-bound name to its exported names so we can record values + // in `exportNameToValueMap` once the binding's value is known. + const existing = localNameToExportedNamesMap.get(localName); + if (existing === undefined) { + localNameToExportedNamesMap.set(localName, [exportedName]); + } else { + existing.push(exportedName); + } } } } - function recordIdentifierWithValue(localName: string, value: string, scope = currentScope) { + function recordIdentifierWithValue( + localName: string, + value: string, + scope = currentScope, + fromCss = false, + ) { scope.identifiers.set(localName, value); // Only record exports for module-level (root scope) declarations - if (scope === rootScope && localNameToExportedNameMap.has(localName)) { - const exportedName = localNameToExportedNameMap.get(localName)!; - exportNameToValueMap.set(exportedName, value); + if (scope === rootScope && localNameToExportedNamesMap.has(localName)) { + for (const exportedName of localNameToExportedNamesMap.get(localName)!) { + exportNameToValueMap.set(exportedName, { kind: 'value', value, fromCss, localName }); + } + } + } + + function isCssTagTemplate(node: TaggedTemplateExpression, scope: Scope): boolean { + if (!(node.tag.type === 'Identifier' && cssTagNames.has(node.tag.name))) { + return false; + } + + // A local binding shadowing the imported tag means this is not the ecij tag + for (let current: Scope | null = scope; current !== null; current = current.parent) { + if (current.identifiers.has(node.tag.name)) { + return false; + } } + + return true; } function handleTaggedTemplateExpression( localName: string | undefined, node: TaggedTemplateExpression, scope = currentScope, + exportedAs?: string | undefined, ) { - if (!(hasCSSTagImport && node.tag.type === 'Identifier' && node.tag.name === 'css')) { + if (!isCssTagTemplate(node, scope)) { return; } @@ -187,7 +454,7 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { // and identifier for consistency across builds. // The index is always used to avoid collisions with other variables // with the same name in the same file. - const hash = hashText(`${relativePath}:${index}:${localName}`); + const hash = hashText(`${relativePath}:${index}:${localName ?? exportedAs}`); const className = `${classPrefix}${hash}`; @@ -200,7 +467,16 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { // Record generated class names for css declarations if (localName !== undefined) { - recordIdentifierWithValue(localName, className, scope); + recordIdentifierWithValue(localName, className, scope, true); + } else if (exportedAs !== undefined && scope === rootScope) { + // `export default css\`...\`` has no local name but is reachable + // via the `default` export. + exportNameToValueMap.set(exportedAs, { + kind: 'value', + value: className, + fromCss: true, + localName: undefined, + }); } } @@ -405,23 +681,20 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { } const localName = node.id.name; + const init = node.init == null ? undefined : unwrapExpression(node.init); - switch (node.init?.type) { - case 'TaggedTemplateExpression': - if (node.init.tag.type === 'Identifier' && node.init.tag.name === 'css') { - taggedTemplateExpressionFromVariableDeclarator.add(node.init); - handleTaggedTemplateExpression(localName, node.init, targetScope); - return; - } - break; + if (init !== undefined) { + if (init.type === 'TaggedTemplateExpression' && isCssTagTemplate(init, currentScope)) { + processedTaggedTemplateExpressions.add(init); + handleTaggedTemplateExpression(localName, init, targetScope); + return; + } - case 'Literal': - if (typeof node.init.value === 'string' || typeof node.init.value === 'number') { - const value = String(node.init.value); - recordIdentifierWithValue(localName, value, targetScope); - return; - } - break; + const value = resolveStaticExpression(init); + if (value !== undefined) { + recordIdentifierWithValue(localName, value, targetScope); + return; + } } // Record as unknown value so it shadows outer variables @@ -429,15 +702,60 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { }, TaggedTemplateExpression(node) { - if (!taggedTemplateExpressionFromVariableDeclarator.has(node)) { + if (!processedTaggedTemplateExpressions.has(node)) { // No variable name for inline expressions handleTaggedTemplateExpression(undefined, node); } }, + + // `export default ` — handle css tagged templates and static + // string/number expressions. Identifiers reach the default export through + // the staticExports loop above (which records `default` in + // `localNameToExportedNamesMap`); function/class declarations have no + // static value and are skipped by both branches. + ExportDefaultDeclaration(node) { + const declaration = + node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'ClassDeclaration' || + node.declaration.type === 'TSInterfaceDeclaration' + ? undefined + : unwrapExpression(node.declaration); + if (declaration === undefined) return; + + if ( + declaration.type === 'TaggedTemplateExpression' && + isCssTagTemplate(declaration, currentScope) + ) { + processedTaggedTemplateExpressions.add(declaration); + handleTaggedTemplateExpression(undefined, declaration, currentScope, 'default'); + return; + } + + const value = resolveStaticExpression(declaration); + if (value !== undefined) { + exportNameToValueMap.set('default', { + kind: 'value', + value, + fromCss: false, + localName: undefined, + }); + } + }, }); visitor.visit(parseResult.program); + // Explicit exports that never received a static value must still shadow + // `export *` sources (per ESM, explicit exports win over star re-exports), + // so mark them as present-but-unresolvable. + for (const [localName, exportedNames] of localNameToExportedNamesMap) { + for (const exportedName of exportedNames) { + if (!exportNameToValueMap.has(exportedName)) { + exportNameToValueMap.set(exportedName, { kind: 'unresolved', localName }); + } + } + } + return parsedInfo; } @@ -469,6 +787,194 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { }> = []; const stylesheetDependencies = new Set(); + // True if awaiting `context.load(targetFilePath)` can never settle: the + // target is this module, or its transform is (transitively) awaiting a + // load of this module. + function loadWouldDeadlock(targetFilePath: string): boolean { + const queue = [targetFilePath]; + const seen = new Set(); + + while (queue.length !== 0) { + const current = queue.pop()!; + if (current === filePath) return true; + if (seen.has(current)) continue; + seen.add(current); + const targets = pendingLoads.get(current); + if (targets !== undefined) { + queue.push(...targets.keys()); + } + } + + return false; + } + + // Resolve a `source` import specifier relative to `importer` and ensure + // the target module has been parsed (so its caches are populated). + async function resolveImportedFile( + source: string, + importer: string, + ): Promise { + const resolvedId = await context.resolve(source, importer); + // External modules cannot be parsed for static values + if (resolvedId == null || resolvedId.external !== false) return undefined; + + const targetFilePath = stripQuery(resolvedId.id); + + // Re-run this file's transform when the dependency changes in watch + // mode, since its values are baked into this file's output + if (!targetFilePath.startsWith('\0')) { + context.addWatchFile(targetFilePath); + } + + // Loading a module that is (transitively) awaiting this module's own + // transform would deadlock — e.g. a barrel re-exporting the file + // currently being transformed. Its parse info is served from the cache + // (or disk) instead. Loads of independent in-flight modules are awaited + // normally so their extraction results are complete before use. + if (loadWouldDeadlock(targetFilePath)) { + return targetFilePath; + } + + addPendingLoad(filePath, targetFilePath); + try { + // populate the cache for the imported file + await context.load(resolvedId); + } finally { + removePendingLoad(filePath, targetFilePath); + } + + return targetFilePath; + } + + // A statically-resolved export: a concrete value, a module namespace + // (`export * as ns from 'mod'`), or a name that exists but has no static + // value. `origin` identifies the terminal binding, so the `export *` + // ambiguity check can tell two paths to the same binding apart from + // genuinely conflicting ones. `files` lists the modules traversed to reach + // the value; their stylesheets must be pulled into the consumer. + type ResolvedExport = + | { kind: 'value'; value: string; origin: string; files: readonly string[] } + | { kind: 'namespace'; filePath: string; files: readonly string[] } + | { kind: 'unresolved'; origin: string }; + + // Resolve `exportName` from `targetFilePath`, following re-export chains + // (`export { x } from 'mod'`) and `export *` aggregations. + async function resolveExportTarget( + targetFilePath: string, + exportName: string, + visited: Set, + ): Promise { + // Guard against cyclic re-export chains + const cacheKey = `${targetFilePath}::${exportName}`; + if (visited.has(cacheKey)) return undefined; + visited.add(cacheKey); + + const { exportNameToValueMap, exportStarSources } = await parseFile(context, targetFilePath); + + const record = exportNameToValueMap.get(exportName); + if (record !== undefined) { + // Key the binding by its local name when known: two export aliases of + // the same binding reached through different `export *` paths are not + // ambiguous per ECMA-262 ResolveExport. + const origin = + record.kind === 'value' || record.kind === 'unresolved' + ? `${targetFilePath}::${record.localName ?? exportName}` + : cacheKey; + + switch (record.kind) { + case 'value': + // css`` class names are only usable if their declaration was + // actually extracted; a skipped (unresolvable interpolations) or + // filtered-out (e.g. node_modules) declaration would leave the + // class without a rule in the emitted stylesheets. + if (record.fromCss && !extractedClassesPerFile.get(targetFilePath)?.has(record.value)) { + return { kind: 'unresolved', origin }; + } + return { + kind: 'value', + value: record.value, + origin, + files: [targetFilePath], + }; + case 'reexport': { + const nextFilePath = await resolveImportedFile(record.source, targetFilePath); + if (nextFilePath === undefined) return undefined; + const target = await resolveExportTarget(nextFilePath, record.imported, visited); + if (target === undefined || target.kind === 'unresolved') return target; + return { ...target, files: [targetFilePath, ...target.files] }; + } + case 'namespace-reexport': { + const namespaceFilePath = await resolveImportedFile(record.source, targetFilePath); + if (namespaceFilePath === undefined) return undefined; + return { kind: 'namespace', filePath: namespaceFilePath, files: [targetFilePath] }; + } + case 'unresolved': + return { kind: 'unresolved', origin }; + } + } + + // `export * from 'mod'` — excludes the default export. All sources are + // probed: per ESM, a name provided by multiple star sources (through + // different bindings) is ambiguous and not exported at all. + if (exportName !== 'default') { + const candidates = new Map(); + + for (const source of exportStarSources) { + const nextFilePath = await resolveImportedFile(source, targetFilePath); + if (nextFilePath === undefined) continue; + const target = await resolveExportTarget(nextFilePath, exportName, visited); + if (target === undefined) continue; + const targetKey = target.kind === 'namespace' ? `ns:${target.filePath}` : target.origin; + candidates.set(targetKey, target); + } + + if (candidates.size === 1) { + const target = candidates.values().next().value!; + if (target.kind === 'unresolved') return target; + return { ...target, files: [targetFilePath, ...target.files] }; + } + if (candidates.size > 1) { + // Ambiguous `export *` name + return { kind: 'unresolved', origin: cacheKey }; + } + } + + return undefined; + } + + function addStylesheetDependencies(files: readonly string[]) { + for (const file of files) { + const stylesheet = stylesheetImportPerFile.get(file); + if (stylesheet !== undefined) { + stylesheetDependencies.add(stylesheet); + } + } + } + + // Resolve `exportName` from `targetFilePath` to a static value. Stylesheet + // dependencies are recorded only when resolution succeeds, so probing a + // module that does not provide the value never drags its CSS in. + async function resolveExportValue( + targetFilePath: string, + exportName: string, + ): Promise { + const target = await resolveExportTarget(targetFilePath, exportName, new Set()); + if (target === undefined || target.kind !== 'value') return undefined; + + addStylesheetDependencies(target.files); + return target.value; + } + + // True if `name` is bound anywhere along the scope chain (i.e. shadows imports). + function isBoundInScopeChain(name: string, scope: Scope): boolean { + let current: Scope | null = scope; + while (current !== null) { + if (current.identifiers.has(name)) return true; + current = current.parent; + } + return false; + } + // Helper to resolve a value from an identifier, walking up the scope chain async function resolveValue(identifierName: string, scope: Scope): Promise { if (scope.identifiers.has(identifierName)) { @@ -484,36 +990,83 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { } // Check if it's an imported identifier - if (importedIdentifiers.has(identifierName)) { - const { source, imported } = importedIdentifiers.get(identifierName)!; + const importEntry = importedIdentifiers.get(identifierName); + if (importEntry === undefined) return undefined; - // Resolve the import path relative to the importer - const resolvedId = await context.resolve(source, filePath); + // Namespace imports cannot be resolved to a single value here — + // they need to be accessed via member expression (`ns.foo`). + if (importEntry.kind === 'namespace') return undefined; - if (resolvedId != null) { - // populate the cache for the imported file - await context.load(resolvedId); + const importedFilePath = await resolveImportedFile(importEntry.source, filePath); + if (importedFilePath === undefined) return undefined; - const { id } = resolvedId; + return resolveExportValue(importedFilePath, importEntry.imported); + } - const { exportNameToValueMap } = await parseFile(context, id); + // Resolve `${ns.foo}` / `${ns.inner.foo}` member paths where the base + // identifier is a namespace import (`import * as ns from 'mod'`) or a + // named import that (transitively) resolves to a namespace re-export + // (`export * as ns from 'mod'`). + async function resolveMemberPath( + names: readonly string[], + scope: Scope, + ): Promise { + const namespaceName = names[0]!; + + // A local binding shadows the import + if (isBoundInScopeChain(namespaceName, scope)) return undefined; + + const importEntry = importedIdentifiers.get(namespaceName); + if (importEntry === undefined) return undefined; + + const importedFilePath = await resolveImportedFile(importEntry.source, filePath); + if (importedFilePath === undefined) return undefined; + + const traversedFiles: string[] = []; + let namespaceFilePath: string; + + if (importEntry.kind === 'namespace') { + namespaceFilePath = importedFilePath; + } else { + // Named import — it must resolve to a namespace re-export + const target = await resolveExportTarget(importedFilePath, importEntry.imported, new Set()); + if (target === undefined || target.kind !== 'namespace') return undefined; + traversedFiles.push(...target.files); + namespaceFilePath = target.filePath; + } - if (exportNameToValueMap.has(imported)) { - if (stylesheetImportPerFile.has(id)) { - stylesheetDependencies.add(stylesheetImportPerFile.get(id)!); - } - return exportNameToValueMap.get(imported)!; - } - } + // Intermediate members must each resolve to a nested namespace + // (`export * as inner from 'mod'`); the final member is the value. + for (let i = 1; i < names.length - 1; i++) { + const target = await resolveExportTarget(namespaceFilePath, names[i]!, new Set()); + if (target === undefined || target.kind !== 'namespace') return undefined; + traversedFiles.push(...target.files); + namespaceFilePath = target.filePath; } - return; + const target = await resolveExportTarget( + namespaceFilePath, + names[names.length - 1]!, + new Set(), + ); + if (target === undefined || target.kind !== 'value') return undefined; + + addStylesheetDependencies([...traversedFiles, ...target.files]); + return target.value; } + // Class names extracted from this file so far. Registered up front and + // filled incrementally, so concurrent/cyclic resolutions of this module's + // exports see classes as soon as their declarations are extracted. + const extractedClasses = new Set(); + extractedClassesPerFile.set(filePath, extractedClasses); + // Helper to add a processed CSS declaration function addProcessedDeclaration(declaration: Declaration, cssContent: string) { const { className, node } = declaration; + extractedClasses.add(className); + cssExtractions.push({ className, cssContent: cssContent.trim(), @@ -536,74 +1089,123 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { addProcessedDeclaration(declaration, cssContent); } - // Pass 2: With interpolations using resolved local references - for (const declaration of declarations) { - if (!declaration.hasInterpolations) continue; - + // Class names generated for this file's own declarations — references to + // them only resolve once the corresponding declaration is extracted. + const ownClassNames = new Set(declarations.map(({ className }) => className)); + + // Resolve all interpolations of one declaration. + // - 'extracted': all resolved, declaration recorded + // - 'deferred': blocked on a same-file class that is not extracted (yet); + // retried unless `finalAttempt`, in which case it warns and skips + // - 'skipped': unresolvable, warning emitted + async function processInterpolatedDeclaration( + declaration: Declaration, + finalAttempt: boolean, + ): Promise<'extracted' | 'deferred' | 'skipped'> { const { quasis, expressions } = declaration.node.quasi; let cssContent = ''; - let allResolved = true; for (let i = 0; i < quasis.length; i++) { cssContent += quasis[i]!.value.raw; if (i < expressions.length) { - const expression = expressions[i]!; - - let resolvedValue: string | undefined; - - if ( - expression.type === 'Literal' && - (typeof expression.value === 'string' || typeof expression.value === 'number') - ) { - resolvedValue = String(expression.value); - } else if ( - expression.type === 'UnaryExpression' && - (expression.operator === '-' || expression.operator === '+') && - expression.argument.type === 'Literal' && - typeof expression.argument.value === 'number' - ) { - resolvedValue = String( - expression.operator === '-' ? -expression.argument.value : expression.argument.value, - ); - } else if (expression.type === 'Identifier') { - resolvedValue = await resolveValue(expression.name, declaration.scope); - - if (resolvedValue === undefined) { - // Cannot resolve - skip this entire css`` block + const expression = unwrapExpression(expressions[i]!); + + let resolvedValue = resolveStaticExpression(expression); + + if (resolvedValue === undefined) { + const memberPath = + expression.type === 'MemberExpression' + ? flattenMemberExpressionPath(expression) + : undefined; + + if (expression.type === 'Identifier') { + resolvedValue = await resolveValue(expression.name, declaration.scope); + + // A class name of a same-file declaration that has not been + // extracted: defer in case it is a forward reference whose + // declaration is still pending; once no progress can be made + // it is a failed extraction and must not leak (no rule exists). + if ( + resolvedValue !== undefined && + ownClassNames.has(resolvedValue) && + !extractedClasses.has(resolvedValue) + ) { + if (!finalAttempt) return 'deferred'; + resolvedValue = undefined; + } + + if (resolvedValue === undefined) { + // Cannot resolve - skip this entire css`` block + context.warn( + { + pluginCode: 'UNRESOLVED_INTERPOLATION', + message: `skipped CSS extraction — could not resolve "${expression.name}" to a static string or number`, + }, + expression.start, + ); + return 'skipped'; + } + } else if (memberPath !== undefined) { + // Namespace member access: `${ns.foo}` / `${ns.inner.foo}` + resolvedValue = await resolveMemberPath(memberPath, declaration.scope); + + if (resolvedValue === undefined) { + context.warn( + { + pluginCode: 'UNRESOLVED_INTERPOLATION', + message: `skipped CSS extraction — could not resolve "${memberPath.join('.')}" to a static string or number`, + }, + expression.start, + ); + return 'skipped'; + } + } else { + // Complex expression - skip this entire css`` block context.warn( { - pluginCode: 'UNRESOLVED_INTERPOLATION', - message: `skipped CSS extraction — could not resolve "${expression.name}" to a static string or number`, + pluginCode: 'COMPLEX_INTERPOLATION', + message: + 'skipped CSS extraction — interpolation is not a static string, number, or identifier', }, expression.start, ); - allResolved = false; - break; + return 'skipped'; } - } else { - // Complex expression - skip this entire css`` block - context.warn( - { - pluginCode: 'COMPLEX_INTERPOLATION', - message: - 'skipped CSS extraction — interpolation is not a static string, number, or identifier', - }, - expression.start, - ); - allResolved = false; - break; } cssContent += resolvedValue; } } - // Only process if all interpolations were resolved - if (allResolved) { - addProcessedDeclaration(declaration, cssContent); + addProcessedDeclaration(declaration, cssContent); + return 'extracted'; + } + + // Pass 2: With interpolations using resolved local references. + // Declarations blocked on same-file forward references are retried until + // no further progress is made. + let remaining = declarations.filter(({ hasInterpolations }) => hasInterpolations); + + while (remaining.length !== 0) { + const deferred: Declaration[] = []; + + for (const declaration of remaining) { + if ((await processInterpolatedDeclaration(declaration, false)) === 'deferred') { + deferred.push(declaration); + } + } + + if (deferred.length === remaining.length) { + // No progress — the deferred declarations are unresolvable + for (const declaration of deferred) { + await processInterpolatedDeclaration(declaration, true); + } + break; } + + remaining = deferred; } if (replacements.length === 0) { @@ -654,10 +1256,25 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { buildEnd() { // Clear caches between builds parsedFileInfoCache.clear(); + extractedClassesPerFile.clear(); + pendingLoads.clear(); stylesheetImportPerFile.clear(); extractedCssPerFile.clear(); }, + watchChange(id) { + // Evict the per-file caches so the next transform re-reads the changed + // module instead of serving stale parsed values + parsedFileInfoCache.delete(id); + extractedClassesPerFile.delete(id); + + const cssModuleId = stylesheetImportPerFile.get(id); + if (cssModuleId !== undefined) { + stylesheetImportPerFile.delete(id); + extractedCssPerFile.delete(cssModuleId); + } + }, + resolveId(id) { // Ensure CSS modules are treated as having side effects if (extractedCssPerFile.has(id)) { @@ -697,8 +1314,7 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { } // Remove query parameters from the ID - const queryIndex = id.indexOf('?'); - const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex); + const cleanId = stripQuery(id); // Extract CSS from the code const { transformedCode, hasExtractions, cssContent, stylesheetDependencies } = @@ -720,9 +1336,10 @@ export function ecij(configuration?: Configuration | undefined | null): Plugin { const hash = hashText(cssContent); const cssModuleId = `${cleanId}.${hash}.css`; - // Store the CSS extractions for this file + // Store the CSS extractions for this file. Keyed by the query-less id + // so cross-module resolution and watchChange eviction can find them. extractedCssPerFile.set(cssModuleId, cssContent); - stylesheetImportPerFile.set(id, cssModuleId); + stylesheetImportPerFile.set(cleanId, cssModuleId); const importStatements: string[] = []; diff --git a/test/fixtures/ambiguous-a.ts b/test/fixtures/ambiguous-a.ts new file mode 100644 index 0000000..ea87f7c --- /dev/null +++ b/test/fixtures/ambiguous-a.ts @@ -0,0 +1 @@ +export const accent = 'red'; diff --git a/test/fixtures/ambiguous-b.ts b/test/fixtures/ambiguous-b.ts new file mode 100644 index 0000000..6a4a4ac --- /dev/null +++ b/test/fixtures/ambiguous-b.ts @@ -0,0 +1 @@ +export const accent = 'blue'; diff --git a/test/fixtures/ambiguous-barrel.ts b/test/fixtures/ambiguous-barrel.ts new file mode 100644 index 0000000..2d5822f --- /dev/null +++ b/test/fixtures/ambiguous-barrel.ts @@ -0,0 +1,5 @@ +// @ts-nocheck — the ambiguity below is deliberate. +// `accent` is provided by two different bindings — per ESM the name is +// ambiguous and not exported at all. +export * from './ambiguous-a'; +export * from './ambiguous-b'; diff --git a/test/fixtures/ambiguous.input.ts b/test/fixtures/ambiguous.input.ts new file mode 100644 index 0000000..500b424 --- /dev/null +++ b/test/fixtures/ambiguous.input.ts @@ -0,0 +1,10 @@ +// @ts-nocheck — `accent` is ambiguous, so the namespace exposes no such member +import { css } from 'ecij'; + +import * as ambiguous from './ambiguous-barrel'; + +// At runtime `ambiguous.accent` is undefined (ambiguous star exports are +// excluded from namespace objects) — the plugin must warn, not pick one. +export const usesAmbiguous = css` + color: ${ambiguous.accent}; +`; diff --git a/test/fixtures/barrel-a.ts b/test/fixtures/barrel-a.ts new file mode 100644 index 0000000..f302dd3 --- /dev/null +++ b/test/fixtures/barrel-a.ts @@ -0,0 +1,8 @@ +import { css } from 'ecij'; + +export const aColor = 'aqua'; + +export const aClass = css` + /* a */ + color: ${aColor}; +`; diff --git a/test/fixtures/barrel-b.ts b/test/fixtures/barrel-b.ts new file mode 100644 index 0000000..9ff4942 --- /dev/null +++ b/test/fixtures/barrel-b.ts @@ -0,0 +1,12 @@ +import { css } from 'ecij'; + +export const bColor = 'beige'; + +export const bClass = css` + /* b */ + color: ${bColor}; +`; + +// `b` also defines `shared` — barrel-c re-exports an explicit `shared` +// that should win over the `export *` collision. +export const shared = 'b-loses'; diff --git a/test/fixtures/barrel-c.ts b/test/fixtures/barrel-c.ts new file mode 100644 index 0000000..d55a0e2 --- /dev/null +++ b/test/fixtures/barrel-c.ts @@ -0,0 +1 @@ +export const shared = 'c-wins'; diff --git a/test/fixtures/barrel-d.ts b/test/fixtures/barrel-d.ts new file mode 100644 index 0000000..92b2347 --- /dev/null +++ b/test/fixtures/barrel-d.ts @@ -0,0 +1 @@ +export const dColor = 'darkcyan'; diff --git a/test/fixtures/barrel-deep-only.input.ts b/test/fixtures/barrel-deep-only.input.ts new file mode 100644 index 0000000..7144129 --- /dev/null +++ b/test/fixtures/barrel-deep-only.input.ts @@ -0,0 +1,11 @@ +import { css } from 'ecij'; + +// Resolving `dColor` probes barrel-a and barrel-b (which fail to provide it) +// before the nested barrel — their stylesheets must NOT be dragged into this +// bundle, since nothing from them is used here. +import { dColor } from './barrel'; + +export const usesOnlyDeep = css` + /* uses-only-deep */ + color: ${dColor}; +`; diff --git a/test/fixtures/barrel-nested.ts b/test/fixtures/barrel-nested.ts new file mode 100644 index 0000000..2671fba --- /dev/null +++ b/test/fixtures/barrel-nested.ts @@ -0,0 +1,4 @@ +// Nested barrel — re-exports through another barrel layer. +// `dColor` is reachable from the top-level barrel only through this hop. +export * from './barrel-a'; +export * from './barrel-d'; diff --git a/test/fixtures/barrel.input.ts b/test/fixtures/barrel.input.ts new file mode 100644 index 0000000..7067ebe --- /dev/null +++ b/test/fixtures/barrel.input.ts @@ -0,0 +1,39 @@ +import { css } from 'ecij'; + +// Pull a mix of names through a barrel file: +// - `aColor`, `aClass` come from barrel-a via `export *` +// - `bColor`, `bClass` come from barrel-b via `export *` +// - `shared` is exposed by both barrel-b (via `export *`) and barrel-c (explicit +// `export { shared }`); the explicit re-export must win +// - `aClass` is also reachable through a nested barrel, but should still resolve +// to the same value (two star paths to the *same* binding are not ambiguous). +// - `dColor` is only reachable through the nested barrel (depth-2 `export *`). +import { aColor, aClass, bColor, bClass, shared, dColor } from './barrel'; + +export const usesBarrelA = css` + /* uses-a */ + color: ${aColor}; + + &.${aClass} { + color: red; + } +`; + +export const usesBarrelB = css` + /* uses-b */ + color: ${bColor}; + + &.${bClass} { + color: red; + } +`; + +export const usesBarrelShared = css` + /* uses-shared */ + color: ${shared}; +`; + +export const usesBarrelDeep = css` + /* uses-deep */ + color: ${dColor}; +`; diff --git a/test/fixtures/barrel.ts b/test/fixtures/barrel.ts new file mode 100644 index 0000000..34f6f57 --- /dev/null +++ b/test/fixtures/barrel.ts @@ -0,0 +1,10 @@ +// A barrel file with a mix of explicit re-exports and `export *` aggregation. +// Explicit named re-exports must take precedence over `export *` collisions. +export { shared } from './barrel-c'; +export * from './barrel-a'; +export * from './barrel-b'; +export * from './barrel-nested'; + +// Local export that shadows `aColor` re-exported via `export * from './barrel-a'`. +// Per spec, an explicit named export wins over `export *` for the same name. +export const aColor = 'locally-overridden'; diff --git a/test/fixtures/broken-export.input.ts b/test/fixtures/broken-export.input.ts new file mode 100644 index 0000000..e45e58d --- /dev/null +++ b/test/fixtures/broken-export.input.ts @@ -0,0 +1,9 @@ +import { css } from 'ecij'; + +import { brokenClass } from './broken-export'; + +export const usesBrokenClass = css` + &.${brokenClass} { + color: red; + } +`; diff --git a/test/fixtures/broken-export.ts b/test/fixtures/broken-export.ts new file mode 100644 index 0000000..ad9d4ec --- /dev/null +++ b/test/fixtures/broken-export.ts @@ -0,0 +1,11 @@ +import { css } from 'ecij'; + +function dynamicPadding(): number { + return 4; +} + +// Extraction of this declaration fails (complex interpolation), so its class +// name must not leak into consumers as if its rule existed. +export const brokenClass = css` + padding: ${dynamicPadding()}px; +`; diff --git a/test/fixtures/cycle-a.input.ts b/test/fixtures/cycle-a.input.ts new file mode 100644 index 0000000..a1df1d7 --- /dev/null +++ b/test/fixtures/cycle-a.input.ts @@ -0,0 +1,17 @@ +import { css } from 'ecij'; + +// Mutual import cycle: this module uses cycle-b's class while cycle-b uses +// this module's class. Resolution must neither deadlock nor lose classes that +// are already extracted when the cycle is entered. +import { bClass } from './cycle-b'; + +export const aClass = css` + /* cycle-a */ + color: red; +`; + +export const usesB = css` + &.${bClass} { + color: blue; + } +`; diff --git a/test/fixtures/cycle-b.ts b/test/fixtures/cycle-b.ts new file mode 100644 index 0000000..d4f7803 --- /dev/null +++ b/test/fixtures/cycle-b.ts @@ -0,0 +1,10 @@ +import { css } from 'ecij'; + +import { aClass } from './cycle-a.input'; + +export const bClass = css` + /* cycle-b */ + &.${aClass} { + color: green; + } +`; diff --git a/test/fixtures/default-passthrough-source.ts b/test/fixtures/default-passthrough-source.ts new file mode 100644 index 0000000..533044a --- /dev/null +++ b/test/fixtures/default-passthrough-source.ts @@ -0,0 +1,5 @@ +export default 'mediumseagreen'; + +// Decoy: resolving the re-exported default-import binding below must pick the +// default export above, not this same-named named export. +export const passthroughDefault = 'wrong-named-export'; diff --git a/test/fixtures/default-passthrough.ts b/test/fixtures/default-passthrough.ts new file mode 100644 index 0000000..7f757d1 --- /dev/null +++ b/test/fixtures/default-passthrough.ts @@ -0,0 +1,5 @@ +// oxc normalizes this pair into a re-export entry that points at the *local* +// binding name instead of 'default' — the plugin must map it back. +import passthroughDefault from './default-passthrough-source'; + +export { passthroughDefault }; diff --git a/test/fixtures/export-default-css.ts b/test/fixtures/export-default-css.ts new file mode 100644 index 0000000..783e0af --- /dev/null +++ b/test/fixtures/export-default-css.ts @@ -0,0 +1,7 @@ +import { css } from 'ecij'; + +// Default-exported css tagged template (no local binding) +export default css` + /* default css */ + border: 2px dashed teal; +`; diff --git a/test/fixtures/export-default-literal.ts b/test/fixtures/export-default-literal.ts new file mode 100644 index 0000000..afa5e1d --- /dev/null +++ b/test/fixtures/export-default-literal.ts @@ -0,0 +1,2 @@ +// Default-exported string literal +export default 'royalblue'; diff --git a/test/fixtures/export-default-local.ts b/test/fixtures/export-default-local.ts new file mode 100644 index 0000000..fd6d6f0 --- /dev/null +++ b/test/fixtures/export-default-local.ts @@ -0,0 +1,9 @@ +import { css } from 'ecij'; + +const localClass = css` + /* default-local */ + display: grid; +`; + +// Default export pointing at a locally-bound css class +export default localClass; diff --git a/test/fixtures/export-default-negative.ts b/test/fixtures/export-default-negative.ts new file mode 100644 index 0000000..ccc593f --- /dev/null +++ b/test/fixtures/export-default-negative.ts @@ -0,0 +1,2 @@ +// Default-exported signed number literal +export default -5; diff --git a/test/fixtures/export-default-wrapped-css.ts b/test/fixtures/export-default-wrapped-css.ts new file mode 100644 index 0000000..21fade1 --- /dev/null +++ b/test/fixtures/export-default-wrapped-css.ts @@ -0,0 +1,9 @@ +import { css } from 'ecij'; + +// Default-exported css tagged template behind a TS `as` assertion — the +// wrapper (like parentheses) must be unwrapped for the declaration to be +// recognized. +export default css` + /* wrapped-default */ + display: flex; +` as string; diff --git a/test/fixtures/external-barrel.ts b/test/fixtures/external-barrel.ts new file mode 100644 index 0000000..536abcb --- /dev/null +++ b/test/fixtures/external-barrel.ts @@ -0,0 +1,6 @@ +// @ts-nocheck — '@acme/tokens' deliberately does not exist; it is marked +// external in the consuming test's build options. +// The first star source is an external package that cannot be parsed — +// resolution must skip it gracefully and find the value in the next source. +export * from '@acme/tokens'; +export * from './external-tokens'; diff --git a/test/fixtures/external-star.input.ts b/test/fixtures/external-star.input.ts new file mode 100644 index 0000000..4a9f249 --- /dev/null +++ b/test/fixtures/external-star.input.ts @@ -0,0 +1,8 @@ +import { css } from 'ecij'; + +import { brandColor } from './external-barrel'; + +export const usesExternalBarrel = css` + /* uses external-barrel */ + color: ${brandColor}; +`; diff --git a/test/fixtures/external-tokens.ts b/test/fixtures/external-tokens.ts new file mode 100644 index 0000000..8f85fee --- /dev/null +++ b/test/fixtures/external-tokens.ts @@ -0,0 +1 @@ +export const brandColor = 'goldenrod'; diff --git a/test/fixtures/import-export-hardening.input.ts b/test/fixtures/import-export-hardening.input.ts new file mode 100644 index 0000000..be19812 --- /dev/null +++ b/test/fixtures/import-export-hardening.input.ts @@ -0,0 +1,49 @@ +import { css } from 'ecij'; + +// Default import re-exported under its local name (`import d from 'mod'; export { d };`) +import { passthroughDefault } from './default-passthrough'; +// Default exports with static expressions the evaluator must unwrap +import negativePad from './export-default-negative'; +import wrappedClass from './export-default-wrapped-css'; +// Namespace re-export reached through a chained named re-export +import { styles as chainedStyles } from './ns-chain'; +// Namespace re-export reached through an `export *` aggregation +import { styles as starChainedStyles } from './ns-chain-star'; +// Nested namespace member access (`ns.inner.member`) +import * as outerNs from './reexports-namespace'; + +export const usesPassthroughDefault = css` + /* uses passthrough-default */ + color: ${passthroughDefault}; +`; + +export const usesChainedNamespace = css` + /* uses chained-namespace */ + color: ${chainedStyles.accentColor}; +`; + +export const usesStarChainedNamespace = css` + /* uses star-chained-namespace */ + font-size: ${starChainedStyles.accentSize}px; +`; + +export const usesNestedNamespaceMember = css` + /* uses nested-namespace-member */ + background: ${outerNs.styles.accentColor}; + + &.${outerNs.styles.accentClass} { + color: red; + } +`; + +export const usesNegativeDefault = css` + /* uses negative-default */ + margin: ${negativePad}px; +`; + +export const usesWrappedDefaultCss = css` + /* uses wrapped-default-css */ + &.${wrappedClass} { + color: red; + } +`; diff --git a/test/fixtures/import-export.input.ts b/test/fixtures/import-export.input.ts new file mode 100644 index 0000000..bb626a0 --- /dev/null +++ b/test/fixtures/import-export.input.ts @@ -0,0 +1,109 @@ +import { css } from 'ecij'; + +// Default import — resolves to a css class created in another module +import defaultCssClass from './export-default-css'; +// Default import — resolves to a string literal +import defaultColor from './export-default-literal'; +// Default import — resolves to a locally-bound css class via `export default ` +import defaultLocalClass from './export-default-local'; +// Namespace import — resolves members through the imported module's exports +import * as namedStyles from './named-styles'; +// Re-export: named-as-named (passes through unchanged or renamed) +import { accentColor as reexportColor, renamedAccentClass } from './reexports-named'; +// Re-export: default-as-named +import { defaultStyle } from './reexports-named'; +// Re-export: named-as-default +import reexportDefault from './reexports-named'; +// Namespace re-export accessed through a named import +import { styles } from './reexports-namespace'; +// `export * from 'mod'` — looks up via star aggregation (excludes default) +import { accentColor as starAccentColor, accentClass as starAccentClass } from './reexports-star'; +// Inline type modifier mixed with a value import; `typedTone` resolves through +// the barrel's `export *` despite the barrel's type-only re-exports of a decoy +import { type LocalSpec, typedTone } from './typed-barrel'; +// Type-only imports — erased at runtime and ignored by the plugin +import type { ToneType } from './typed-tokens'; + +export const usesDefaultCssClass = css` + /* uses default-css */ + &.${defaultCssClass} { + color: red; + } +`; + +export const usesDefaultLiteral = css` + /* uses default-literal */ + color: ${defaultColor}; +`; + +export const usesDefaultLocalClass = css` + /* uses default-local */ + &.${defaultLocalClass} { + border: 1px solid; + } +`; + +export const usesNamespaceImport = css` + /* uses namespace */ + background: ${namedStyles.accentColor}; + font-size: ${namedStyles.accentSize}px; + + &.${namedStyles.accentClass} { + color: red; + } +`; + +export const usesReexportNamed = css` + /* uses reexport-named */ + color: ${reexportColor}; + + &.${renamedAccentClass} { + color: red; + } +`; + +export const usesReexportDefaultAsNamed = css` + /* uses reexport-default-as-named */ + &.${defaultStyle} { + color: red; + } +`; + +export const usesReexportNamedAsDefault = css` + /* uses reexport-named-as-default */ + color: ${reexportDefault}; +`; + +export const usesStarReexport = css` + /* uses star-reexport */ + color: ${starAccentColor}; + + &.${starAccentClass} { + color: red; + } +`; + +export const usesNamespaceReexport = css` + /* uses namespace-reexport */ + background: ${styles.accentColor}; + + &.${styles.accentClass} { + color: red; + } +`; + +// `export default css\`...\`` from the entry file itself +export default css` + /* entry-default */ + font-size: 12px; +`; + +const typedOutline: ToneType = 'peru'; +const typedWidth: LocalSpec = '6px'; + +export const usesTypeOnlyBarrel = css` + /* uses type-only-barrel */ + color: ${typedTone}; + outline-color: ${typedOutline}; + width: ${typedWidth}; +`; diff --git a/test/fixtures/named-styles.ts b/test/fixtures/named-styles.ts new file mode 100644 index 0000000..fc0c10e --- /dev/null +++ b/test/fixtures/named-styles.ts @@ -0,0 +1,10 @@ +import { css } from 'ecij'; + +export const accentColor = 'crimson'; +export const accentSize = 14; + +export const accentClass = css` + /* accent */ + background: ${accentColor}; + font-size: ${accentSize}px; +`; diff --git a/test/fixtures/namespace-edge-cases.input.ts b/test/fixtures/namespace-edge-cases.input.ts new file mode 100644 index 0000000..656384b --- /dev/null +++ b/test/fixtures/namespace-edge-cases.input.ts @@ -0,0 +1,32 @@ +// @ts-nocheck — this fixture deliberately uses a namespace import as a scalar +// and accesses a non-existent namespace member to validate the plugin's +// UNRESOLVED_INTERPOLATION warnings. +import { css } from 'ecij'; + +import * as namedStyles from './named-styles'; +import { styles } from './reexports-namespace'; +import * as starOnly from './star-over-default'; + +// `${ns.unknownMember}` — namespace exists, but the member does not. +// Should emit an UNRESOLVED_INTERPOLATION warning naming `ns.member`. +export const missingMember = css` + color: ${namedStyles.unknownMember}; +`; + +// `${ns}` — using a namespace import as a scalar value is meaningless and +// should fall through to the standard UNRESOLVED_INTERPOLATION warning. +export const namespaceAsScalar = css` + color: ${namedStyles}; +`; + +// `${reexportedNs}` — same thing but for a namespace reached via +// `export * as ns from 'mod'` and then imported by name. +export const namespaceReexportAsScalar = css` + color: ${styles}; +`; + +// `export * from 'mod'` never forwards `default` — `starOnly.default` must +// warn instead of resolving the source module's default export ('royalblue'). +export const starDefaultAsScalar = css` + color: ${starOnly.default}; +`; diff --git a/test/fixtures/ns-chain-star.ts b/test/fixtures/ns-chain-star.ts new file mode 100644 index 0000000..d184619 --- /dev/null +++ b/test/fixtures/ns-chain-star.ts @@ -0,0 +1,2 @@ +// An `export *` hop in front of a namespace re-export +export * from './reexports-namespace'; diff --git a/test/fixtures/ns-chain.ts b/test/fixtures/ns-chain.ts new file mode 100644 index 0000000..9211547 --- /dev/null +++ b/test/fixtures/ns-chain.ts @@ -0,0 +1,2 @@ +// A named re-export hop in front of a namespace re-export +export { styles } from './reexports-namespace'; diff --git a/test/fixtures/reexports-named.ts b/test/fixtures/reexports-named.ts new file mode 100644 index 0000000..84878f0 --- /dev/null +++ b/test/fixtures/reexports-named.ts @@ -0,0 +1,8 @@ +// Re-export named bindings unchanged and renamed +export { accentColor, accentClass as renamedAccentClass } from './named-styles'; + +// Re-export a default as a named binding +export { default as defaultStyle } from './export-default-css'; + +// Re-export a named binding as the default +export { accentColor as default } from './named-styles'; diff --git a/test/fixtures/reexports-namespace.ts b/test/fixtures/reexports-namespace.ts new file mode 100644 index 0000000..6d3aaf3 --- /dev/null +++ b/test/fixtures/reexports-namespace.ts @@ -0,0 +1,2 @@ +// Namespace re-export: `export * as ns from 'mod'` +export * as styles from './named-styles'; diff --git a/test/fixtures/reexports-star.ts b/test/fixtures/reexports-star.ts new file mode 100644 index 0000000..7db48a3 --- /dev/null +++ b/test/fixtures/reexports-star.ts @@ -0,0 +1,2 @@ +// `export *` aggregation (does not include `default`) +export * from './named-styles'; diff --git a/test/fixtures/same-file-classes.input.ts b/test/fixtures/same-file-classes.input.ts new file mode 100644 index 0000000..97f0db0 --- /dev/null +++ b/test/fixtures/same-file-classes.input.ts @@ -0,0 +1,33 @@ +// @ts-nocheck — the forward reference below is deliberate (it is valid once +// the plugin replaces both templates with class-name strings). +import { css } from 'ecij'; + +function dynamicPad(): number { + return 4; +} + +const forwardColor = 'green'; + +// References a class declared *later* in the file — must still resolve. +export const usesForward = css` + &.${forwardClass} { + color: red; + } +`; + +export const forwardClass = css` + /* forward */ + color: ${forwardColor}; +`; + +// Extraction of this declaration fails (complex interpolation)... +const brokenSameFile = css` + padding: ${dynamicPad()}px; +`; + +// ...so its class name must not leak into this same-file consumer either. +export const usesBrokenSameFile = css` + &.${brokenSameFile} { + color: red; + } +`; diff --git a/test/fixtures/self-barrel-tokens.ts b/test/fixtures/self-barrel-tokens.ts new file mode 100644 index 0000000..84df4af --- /dev/null +++ b/test/fixtures/self-barrel-tokens.ts @@ -0,0 +1 @@ +export const tokenColor = 'teal'; diff --git a/test/fixtures/self-barrel.input.ts b/test/fixtures/self-barrel.input.ts new file mode 100644 index 0000000..649a07f --- /dev/null +++ b/test/fixtures/self-barrel.input.ts @@ -0,0 +1,10 @@ +import { css } from 'ecij'; + +// Importing from a barrel that `export *`s this very file — resolution must +// not deadlock trying to load the module that is currently being transformed. +import { tokenColor } from './self-barrel'; + +export const selfBarrelClass = css` + /* self-barrel */ + color: ${tokenColor}; +`; diff --git a/test/fixtures/self-barrel.ts b/test/fixtures/self-barrel.ts new file mode 100644 index 0000000..ce92f65 --- /dev/null +++ b/test/fixtures/self-barrel.ts @@ -0,0 +1,4 @@ +// Self-referencing barrel: the star sources include the module that is being +// transformed when this barrel is consulted. +export * from './self-barrel.input'; +export * from './self-barrel-tokens'; diff --git a/test/fixtures/sibling-consumer.ts b/test/fixtures/sibling-consumer.ts new file mode 100644 index 0000000..56115a8 --- /dev/null +++ b/test/fixtures/sibling-consumer.ts @@ -0,0 +1,10 @@ +import { css } from 'ecij'; + +import { producerClass } from './sibling-producer'; + +export const consumerClass = css` + /* consumer */ + &.${producerClass} { + color: blue; + } +`; diff --git a/test/fixtures/sibling-producer.ts b/test/fixtures/sibling-producer.ts new file mode 100644 index 0000000..311b044 --- /dev/null +++ b/test/fixtures/sibling-producer.ts @@ -0,0 +1,8 @@ +import { css } from 'ecij'; + +import { pad } from './sibling-tokens'; + +export const producerClass = css` + /* producer */ + margin: ${pad}px; +`; diff --git a/test/fixtures/sibling-tokens.ts b/test/fixtures/sibling-tokens.ts new file mode 100644 index 0000000..0b248ef --- /dev/null +++ b/test/fixtures/sibling-tokens.ts @@ -0,0 +1 @@ +export const pad = 4; diff --git a/test/fixtures/siblings.input.ts b/test/fixtures/siblings.input.ts new file mode 100644 index 0000000..ef3a4e3 --- /dev/null +++ b/test/fixtures/siblings.input.ts @@ -0,0 +1,18 @@ +import { css } from 'ecij'; + +// Both siblings enter the module graph from here and may be transformed +// concurrently; the consumer resolving the producer's class mid-build must +// wait for its extraction rather than failing spuriously. +import { consumerClass } from './sibling-consumer'; +import { producerClass } from './sibling-producer'; + +export const usesBoth = css` + /* uses-both */ + &.${producerClass} { + color: red; + } + + &.${consumerClass} { + color: green; + } +`; diff --git a/test/fixtures/star-over-default.ts b/test/fixtures/star-over-default.ts new file mode 100644 index 0000000..061a30f --- /dev/null +++ b/test/fixtures/star-over-default.ts @@ -0,0 +1,2 @@ +// `export *` never forwards the default export of the source module +export * from './export-default-literal'; diff --git a/test/fixtures/star-precedence-a.ts b/test/fixtures/star-precedence-a.ts new file mode 100644 index 0000000..841d0ca --- /dev/null +++ b/test/fixtures/star-precedence-a.ts @@ -0,0 +1 @@ +export const tone = 'aqua'; diff --git a/test/fixtures/star-precedence.input.ts b/test/fixtures/star-precedence.input.ts new file mode 100644 index 0000000..83bf664 --- /dev/null +++ b/test/fixtures/star-precedence.input.ts @@ -0,0 +1,8 @@ +import { css } from 'ecij'; + +import { tone } from './star-precedence'; + +export const usesShadowedTone = css` + /* uses shadowed-tone */ + color: ${tone}; +`; diff --git a/test/fixtures/star-precedence.ts b/test/fixtures/star-precedence.ts new file mode 100644 index 0000000..57e162a --- /dev/null +++ b/test/fixtures/star-precedence.ts @@ -0,0 +1,9 @@ +export * from './star-precedence-a'; + +function computeTone(): string { + return 'runtime-only'; +} + +// Explicit export with a non-static value — per ESM it shadows the `tone` +// provided by `export *` above, so the plugin must not resolve it to 'aqua'. +export const tone = computeTone(); diff --git a/test/fixtures/tag-alias.input.ts b/test/fixtures/tag-alias.input.ts new file mode 100644 index 0000000..758476b --- /dev/null +++ b/test/fixtures/tag-alias.input.ts @@ -0,0 +1,6 @@ +import { css as styled } from 'ecij'; + +export const aliasedTagClass = styled` + /* aliased-tag */ + color: olive; +`; diff --git a/test/fixtures/typed-barrel.ts b/test/fixtures/typed-barrel.ts new file mode 100644 index 0000000..d5ac7e2 --- /dev/null +++ b/test/fixtures/typed-barrel.ts @@ -0,0 +1,11 @@ +// @ts-nocheck — tsc flags the type-only star below as colliding with the +// value star (TS2308), but at runtime the type-only star is erased and the +// name is unambiguous; the plugin must reach the same conclusion. +// Type-only re-exports are erased at runtime and must neither provide nor +// shadow values: the `typedTone` VALUE must resolve through the `export *` of +// typed-tokens below ('salmon'), never through the type-only star re-export +// of the decoy (whose `typedTone` is 'WRONG-decoy-value'). +export { type typedTone as DecoyToneType } from './typed-decoy'; +export type * from './typed-decoy'; +export type { ToneType } from './typed-tokens'; +export * from './typed-tokens'; diff --git a/test/fixtures/typed-decoy.ts b/test/fixtures/typed-decoy.ts new file mode 100644 index 0000000..b3ca8ef --- /dev/null +++ b/test/fixtures/typed-decoy.ts @@ -0,0 +1,3 @@ +// Only reachable through type-only re-exports, which are erased at runtime — +// this value must never end up in resolved CSS. +export const typedTone = 'WRONG-decoy-value'; diff --git a/test/fixtures/typed-tokens.ts b/test/fixtures/typed-tokens.ts new file mode 100644 index 0000000..7906248 --- /dev/null +++ b/test/fixtures/typed-tokens.ts @@ -0,0 +1,8 @@ +export const typedTone = 'salmon'; + +export type ToneType = string; + +type LocalSpec = string; + +// Inline type-only local export — erased at runtime +export { type LocalSpec }; diff --git a/test/fixtures/typed.input.ts b/test/fixtures/typed.input.ts new file mode 100644 index 0000000..bfa7c65 --- /dev/null +++ b/test/fixtures/typed.input.ts @@ -0,0 +1,20 @@ +import { css } from 'ecij'; + +// Inline type modifier mixed with a value import +import { type LocalSpec, typedTone } from './typed-barrel'; +// Statement-level type-only import — erased +import type { ToneType } from './typed-tokens'; +// Namespace type-only import — erased +import type * as TypedNs from './typed-tokens'; + +const toneAnnotation: ToneType = 'plum'; +const spec: LocalSpec = '12px'; +const nsAnnotated: TypedNs.ToneType = 'navy'; + +export const usesTypedTone = css` + /* uses typed-tone */ + color: ${typedTone}; + outline-color: ${toneAnnotation}; + width: ${spec}; + border-color: ${nsAnnotated}; +`; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 3d81a15..c594bb2 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,12 +1,16 @@ import { ecij, type Configuration } from 'ecij/plugin'; -import type { RolldownLog } from 'rolldown'; +import { rolldown, type RolldownLog } from 'rolldown'; import { build } from 'vite'; import { expect, test } from 'vitest'; -const normalize = (path: string) => path.replace(/^.*\/test\//, 'test/'); +const normalize = (path: string) => path.replaceAll('\\', '/').replace(/^.*\/test\//, 'test/'); // Helper to run a vite build with the ecij plugin -async function buildWithPlugin(entry: string, pluginOptions?: Configuration) { +async function buildWithPlugin( + entry: string, + pluginOptions?: Configuration, + buildOptions?: { external?: string[] }, +) { const logs: RolldownLog[] = []; const output = await build({ @@ -18,6 +22,7 @@ async function buildWithPlugin(entry: string, pluginOptions?: Configuration) { minify: false, write: false, rolldownOptions: { + ...(buildOptions?.external === undefined ? {} : { external: buildOptions.external }), onLog(level, log, handler) { if (log.plugin === 'ecij') { // Normalize absolute paths so snapshots are stable across machines @@ -1365,6 +1370,892 @@ test('advanced scoping: function parameters, for-of/in, catch, static blocks', a `); }); +test('default, namespace, and re-exported imports/exports', async () => { + const fixturePath = './test/fixtures/import-export.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // Covers: + // - `export default css\`...\`` + // - `export default ` and `export default ` + // - `import defaultName from 'mod'` + // - `import * as ns from 'mod'` with `${ns.foo}` member access + // - `export { x } from 'mod'` and `export { x as y } from 'mod'` + // - `export { default as foo } from 'mod'` + // - `export { foo as default } from 'mod'` + // - `export * from 'mod'` (excludes the default export) + // - `export * as ns from 'mod'` accessed through a named import + // - type-only imports/exports (`import type { T }`, `import { type T, x }`, + // `export type { T } from`, `export { type x } from`, `export type * from`) + // are erased: `typedTone` resolves to the star-provided value ('salmon'), + // never the type-only decoy re-export ('WRONG-decoy-value') + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/import-export.input.ts + var usesDefaultCssClass = "css-b0b5a725"; + var usesDefaultLiteral = "css-cba05f2f"; + var usesDefaultLocalClass = "css-c157f391"; + var usesNamespaceImport = "css-f1972a30"; + var usesReexportNamed = "css-068fc5a6"; + var usesReexportDefaultAsNamed = "css-49a2e17a"; + var usesReexportNamedAsDefault = "css-6677b97e"; + var usesStarReexport = "css-51c1a4dd"; + var usesNamespaceReexport = "css-258eae2b"; + var import_export_input_default = "css-00ba416b"; + var usesTypeOnlyBarrel = "css-6a439424"; + //#endregion + export { import_export_input_default as default, usesDefaultCssClass, usesDefaultLiteral, usesDefaultLocalClass, usesNamespaceImport, usesNamespaceReexport, usesReexportDefaultAsNamed, usesReexportNamed, usesReexportNamedAsDefault, usesStarReexport, usesTypeOnlyBarrel };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-2c1bb3ca { + /* default css */ + border: 2px dashed teal; + }.css-be832145 { + /* default-local */ + display: grid; + }.css-9cfab70f { + /* accent */ + background: crimson; + font-size: 14px; + }.css-b0b5a725 { + /* uses default-css */ + &.css-2c1bb3ca { + color: red; + } + } + + .css-cba05f2f { + /* uses default-literal */ + color: royalblue; + } + + .css-c157f391 { + /* uses default-local */ + &.css-be832145 { + border: 1px solid; + } + } + + .css-f1972a30 { + /* uses namespace */ + background: crimson; + font-size: 14px; + + &.css-9cfab70f { + color: red; + } + } + + .css-068fc5a6 { + /* uses reexport-named */ + color: crimson; + + &.css-9cfab70f { + color: red; + } + } + + .css-49a2e17a { + /* uses reexport-default-as-named */ + &.css-2c1bb3ca { + color: red; + } + } + + .css-6677b97e { + /* uses reexport-named-as-default */ + color: crimson; + } + + .css-51c1a4dd { + /* uses star-reexport */ + color: crimson; + + &.css-9cfab70f { + color: red; + } + } + + .css-258eae2b { + /* uses namespace-reexport */ + background: crimson; + + &.css-9cfab70f { + color: red; + } + } + + .css-00ba416b { + /* entry-default */ + font-size: 12px; + } + + .css-6a439424 { + /* uses type-only-barrel */ + color: salmon; + outline-color: peru; + width: 6px; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('barrel files: `export *` aggregation and explicit-over-star precedence', async () => { + const fixturePath = './test/fixtures/barrel.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // - `aColor` is re-exported from `./barrel-a` via `export *` *and* declared + // locally as `export const aColor = 'locally-overridden'` in the barrel. + // The explicit local export must win. + // - `aClass` resolves through `export * from './barrel-a'` + // - `bColor`/`bClass` resolve through `export * from './barrel-b'` + // - `shared` is exposed by both `export { shared } from './barrel-c'` (c-wins) + // and `export * from './barrel-b'` (b-loses); explicit re-export must win. + // - `barrel-nested.ts` re-exports `aClass` again — it should not double up. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/barrel.input.ts + var usesBarrelA = "css-6a28b4b6"; + var usesBarrelB = "css-2f855474"; + var usesBarrelShared = "css-7c6ff0f4"; + var usesBarrelDeep = "css-147baedb"; + //#endregion + export { usesBarrelA, usesBarrelB, usesBarrelDeep, usesBarrelShared };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-1936cac6 { + /* a */ + color: aqua; + }.css-ad2da54d { + /* b */ + color: beige; + }.css-6a28b4b6 { + /* uses-a */ + color: locally-overridden; + + &.css-1936cac6 { + color: red; + } + } + + .css-2f855474 { + /* uses-b */ + color: beige; + + &.css-ad2da54d { + color: red; + } + } + + .css-7c6ff0f4 { + /* uses-shared */ + color: c-wins; + } + + .css-147baedb { + /* uses-deep */ + color: darkcyan; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('namespace edge cases: unknown member, namespace-as-scalar, ns-reexport-as-scalar', async () => { + const fixturePath = './test/fixtures/namespace-edge-cases.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // - `${ns.unknownMember}` should warn that the member couldn't be resolved. + // - `${ns}` (namespace import used as scalar) should warn — namespaces have no + // single-value reduction. + // - `${reexportedNs}` (a namespace reached through `export * as`, used as + // scalar) should warn for the same reason. + expect(result.js).toMatchInlineSnapshot(` + "//#region \\0rolldown/runtime.js + var __defProp = Object.defineProperty; + var __exportAll = (all, no_symbols) => { + let target = {}; + for (var name in all) __defProp(target, name, { + get: all[name], + enumerable: true + }); + if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" }); + return target; + }; + //#endregion + //#region index.js + function css() { + throw new Error("css\`\` should have been transformed by the ecij plugin"); + } + //#endregion + //#region test/fixtures/named-styles.ts + var named_styles_exports = /* @__PURE__ */ __exportAll({ + accentClass: () => accentClass, + accentColor: () => accentColor, + accentSize: () => 14 + }); + var accentColor = "crimson"; + var accentClass = "css-9cfab70f"; + //#endregion + //#region test/fixtures/namespace-edge-cases.input.ts + var missingMember = css\` + color: \${void 0}; + \`; + var namespaceAsScalar = css\` + color: \${named_styles_exports}; + \`; + var namespaceReexportAsScalar = css\` + color: \${named_styles_exports}; + \`; + var starDefaultAsScalar = css\` + color: \${void 0}; + \`; + //#endregion + export { missingMember, namespaceAsScalar, namespaceReexportAsScalar, starDefaultAsScalar };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-9cfab70f { + /* accent */ + background: crimson; + font-size: 14px; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(` + [ + { + "code": "PLUGIN_WARNING", + "frame": "4: import * as starOnly from "./star-over-default"; + 5: export const missingMember = css\` + 6: color: \${namedStyles.unknownMember}; + ^ + 7: \`; + 8: export const namespaceAsScalar = css\`", + "hook": "transform", + "id": "test/fixtures/namespace-edge-cases.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/namespace-edge-cases.input.ts", + "line": 6, + }, + "message": "skipped CSS extraction — could not resolve "namedStyles.unknownMember" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 217, + }, + { + "code": "PLUGIN_WARNING", + "frame": " 7: \`; + 8: export const namespaceAsScalar = css\` + 9: color: \${namedStyles}; + ^ + 10: \`; + 11: export const namespaceReexportAsScalar = css\`", + "hook": "transform", + "id": "test/fixtures/namespace-edge-cases.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/namespace-edge-cases.input.ts", + "line": 9, + }, + "message": "skipped CSS extraction — could not resolve "namedStyles" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 297, + }, + { + "code": "PLUGIN_WARNING", + "frame": "10: \`; + 11: export const namespaceReexportAsScalar = css\` + 12: color: \${styles}; + ^ + 13: \`; + 14: export const starDefaultAsScalar = css\`", + "hook": "transform", + "id": "test/fixtures/namespace-edge-cases.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/namespace-edge-cases.input.ts", + "line": 12, + }, + "message": "skipped CSS extraction — could not resolve "styles" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 371, + }, + { + "code": "PLUGIN_WARNING", + "frame": "13: \`; + 14: export const starDefaultAsScalar = css\` + 15: color: \${starOnly.default}; + ^ + 16: \`;", + "hook": "transform", + "id": "test/fixtures/namespace-edge-cases.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/namespace-edge-cases.input.ts", + "line": 15, + }, + "message": "skipped CSS extraction — could not resolve "starOnly.default" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 434, + }, + ] + `); +}); + +test('self-referencing barrel resolves without deadlocking', async () => { + const fixturePath = './test/fixtures/self-barrel.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // The barrel `export *`s the entry itself; probing that star source must not + // `context.load` the in-flight module (which would deadlock the build), and + // `tokenColor` must still resolve through the barrel's other star source. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/self-barrel.input.ts + var selfBarrelClass = "css-a96da074"; + //#endregion + export { selfBarrelClass };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-a96da074 { + /* self-barrel */ + color: teal; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('external modules in `export *` chains are skipped gracefully', async () => { + const fixturePath = './test/fixtures/external-star.input.ts'; + const result = await buildWithPlugin(fixturePath, undefined, { external: ['@acme/tokens'] }); + + // The barrel's first star source is external — it cannot be parsed and must + // not be loaded; `brandColor` resolves from the local star source instead. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/external-star.input.ts + var usesExternalBarrel = "css-ff9a316e"; + //#endregion + export { usesExternalBarrel };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-ff9a316e { + /* uses external-barrel */ + color: goldenrod; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('explicit exports with non-static values shadow `export *` sources', async () => { + const fixturePath = './test/fixtures/star-precedence.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // The barrel explicitly exports `tone = computeTone()` (not statically + // resolvable) while `export *` provides a static `tone` from another module. + // Per ESM the explicit export wins, so the plugin must warn and skip rather + // than bake the star source's 'aqua'. + expect(result.js).toMatchInlineSnapshot(` + "//#region index.js + function css() { + throw new Error("css\`\` should have been transformed by the ecij plugin"); + } + //#endregion + //#region test/fixtures/star-precedence.ts + function computeTone() { + return "runtime-only"; + } + //#endregion + //#region test/fixtures/star-precedence.input.ts + var usesShadowedTone = css\` + /* uses shadowed-tone */ + color: \${computeTone()}; + \`; + //#endregion + export { usesShadowedTone };" + `); + expect(result.css).toMatchInlineSnapshot(`undefined`); + expect(result.logs).toMatchInlineSnapshot(` + [ + { + "code": "PLUGIN_WARNING", + "frame": "3: export const usesShadowedTone = css\` + 4: /* uses shadowed-tone */ + 5: color: \${tone}; + ^ + 6: \`;", + "hook": "transform", + "id": "test/fixtures/star-precedence.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/star-precedence.input.ts", + "line": 5, + }, + "message": "skipped CSS extraction — could not resolve "tone" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 145, + }, + ] + `); +}); + +test('import/export hardening: default passthrough, chained namespaces, static default exports', async () => { + const fixturePath = './test/fixtures/import-export-hardening.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // Covers: + // - `import d from 'mod'; export { d };` resolving to mod's *default* export + // (not the decoy named export with the same name) + // - `export * as ns` reached through a chained named re-export + // - `export * as ns` reached through an `export *` hop + // - nested namespace member access (`ns.inner.member`) + // - `export default -5` (signed number literal) + // - `export default css\`...\` as string` (wrapped css tagged template) + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/import-export-hardening.input.ts + var usesPassthroughDefault = "css-f8cd4db8"; + var usesChainedNamespace = "css-dc153ecc"; + var usesStarChainedNamespace = "css-d6528605"; + var usesNestedNamespaceMember = "css-e6f5d50f"; + var usesNegativeDefault = "css-8f4583c7"; + var usesWrappedDefaultCss = "css-12e33db5"; + //#endregion + export { usesChainedNamespace, usesNegativeDefault, usesNestedNamespaceMember, usesPassthroughDefault, usesStarChainedNamespace, usesWrappedDefaultCss };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-9cfab70f { + /* accent */ + background: crimson; + font-size: 14px; + }.css-fb083c1c { + /* wrapped-default */ + display: flex; + }.css-f8cd4db8 { + /* uses passthrough-default */ + color: mediumseagreen; + } + + .css-dc153ecc { + /* uses chained-namespace */ + color: crimson; + } + + .css-d6528605 { + /* uses star-chained-namespace */ + font-size: 14px; + } + + .css-e6f5d50f { + /* uses nested-namespace-member */ + background: crimson; + + &.css-9cfab70f { + color: red; + } + } + + .css-8f4583c7 { + /* uses negative-default */ + margin: -5px; + } + + .css-12e33db5 { + /* uses wrapped-default-css */ + &.css-fb083c1c { + color: red; + } + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('ambiguous `export *` names are not silently resolved', async () => { + const fixturePath = './test/fixtures/ambiguous.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // `accent` is provided by two star sources with different bindings — per + // ESM the name is ambiguous and excluded from the namespace object, so the + // plugin must warn instead of picking the first source's value. + expect(result.js).toMatchInlineSnapshot(` + "//#region index.js + function css() { + throw new Error("css\`\` should have been transformed by the ecij plugin"); + } + //#endregion + //#region test/fixtures/ambiguous.input.ts + var usesAmbiguous = css\` + color: \${void 0}; + \`; + //#endregion + export { usesAmbiguous };" + `); + expect(result.css).toMatchInlineSnapshot(`undefined`); + expect(result.logs).toMatchInlineSnapshot(` + [ + { + "code": "PLUGIN_WARNING", + "frame": "2: import * as ambiguous from "./ambiguous-barrel"; + 3: export const usesAmbiguous = css\` + 4: color: \${ambiguous.accent}; + ^ + 5: \`;", + "hook": "transform", + "id": "test/fixtures/ambiguous.input.ts", + "loc": { + "column": 11, + "file": "test/fixtures/ambiguous.input.ts", + "line": 4, + }, + "message": "skipped CSS extraction — could not resolve "ambiguous.accent" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 122, + }, + ] + `); +}); + +test('class names from skipped extractions do not leak to consumers', async () => { + const fixturePath = './test/fixtures/broken-export.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // broken-export's declaration fails extraction (complex interpolation), so + // its class has no rule anywhere — the consumer must warn and skip instead + // of inlining a phantom class name. + expect(result.js).toMatchInlineSnapshot(` + "//#region index.js + function css() { + throw new Error("css\`\` should have been transformed by the ecij plugin"); + } + //#endregion + //#region test/fixtures/broken-export.ts + function dynamicPadding() { + return 4; + } + //#endregion + //#region test/fixtures/broken-export.input.ts + var usesBrokenClass = css\` + &.\${css\` + padding: \${dynamicPadding()}px; + \`} { + color: red; + } + \`; + //#endregion + export { usesBrokenClass };" + `); + expect(result.css).toMatchInlineSnapshot(`undefined`); + expect(result.logs).toMatchInlineSnapshot(` + [ + { + "code": "PLUGIN_WARNING", + "frame": "4: } + 5: export const brokenClass = css\` + 6: padding: \${dynamicPadding()}px; + ^ + 7: \`;", + "hook": "transform", + "id": "test/fixtures/broken-export.ts", + "loc": { + "column": 13, + "file": "test/fixtures/broken-export.ts", + "line": 6, + }, + "message": "skipped CSS extraction — interpolation is not a static string, number, or identifier", + "plugin": "ecij", + "pluginCode": "COMPLEX_INTERPOLATION", + "pos": 114, + }, + { + "code": "PLUGIN_WARNING", + "frame": "2: import { brokenClass } from "./broken-export"; + 3: export const usesBrokenClass = css\` + 4: &.\${brokenClass} { + ^ + 5: color: red; + 6: }", + "hook": "transform", + "id": "test/fixtures/broken-export.input.ts", + "loc": { + "column": 6, + "file": "test/fixtures/broken-export.input.ts", + "line": 4, + }, + "message": "skipped CSS extraction — could not resolve "brokenClass" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 117, + }, + ] + `); +}); + +test('type-only imports/exports are ignored (plain rolldown, raw TypeScript)', async () => { + // Unlike the Vite pipeline (which strips TypeScript before plugin + // transforms run), plain rolldown hands raw TypeScript to the plugin — this + // exercises the plugin's own `isType` handling for `import type`, + // `import { type x }`, `export type { x } from`, `export { type x } from`, + // and `export type * from`. + const logs: RolldownLog[] = []; + // Plain rolldown does not bundle CSS itself — collect the virtual CSS + // modules the plugin emits instead. + const cssModules = new Map(); + + const bundle = await rolldown({ + input: './test/fixtures/typed.input.ts', + plugins: [ + ecij(), + { + name: 'collect-css', + transform: { + filter: { id: /\.css$/ }, + handler(code, id) { + cssModules.set(normalize(id), code); + return { code: 'export {};', moduleType: 'js' }; + }, + }, + }, + ], + onLog(level, log, handler) { + if (log.plugin === 'ecij') { + if (log.id !== undefined) { + log.id = normalize(log.id); + } + if (log.loc?.file !== undefined) { + log.loc.file = normalize(log.loc.file); + } + logs.push(log); + } else { + handler(level, log); + } + }, + }); + + try { + const { output } = await bundle.generate({ format: 'es' }); + + const jsChunk = output.find((chunk) => chunk.type === 'chunk'); + + // `typedTone` must come from the `export *` value ('salmon'), not the + // type-only decoy re-exports ('WRONG-decoy-value'), and the type-only + // star source must not make the name ambiguous. + expect(jsChunk?.code.trim()).toMatchInlineSnapshot(` + "//#region test/fixtures/typed.input.ts + const usesTypedTone = "css-ad8768ea"; + //#endregion + export { usesTypedTone };" + `); + expect(cssModules).toMatchInlineSnapshot(` + Map { + "test/fixtures/typed.input.ts.90ad1b45.css" => ".css-ad8768ea { + /* uses typed-tone */ + color: salmon; + outline-color: plum; + width: 12px; + border-color: navy; + }", + } + `); + expect(logs).toMatchInlineSnapshot(`[]`); + } finally { + await bundle.close(); + } +}); + +test('mutually importing css modules resolve through the cycle', async () => { + const fixturePath = './test/fixtures/cycle-a.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // cycle-a uses cycle-b's class and vice versa. Loading the in-flight module + // is skipped (it would deadlock), but classes extracted so far are visible, + // so both sides resolve. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/cycle-a.input.ts + var aClass = "css-d4dd33b6"; + var usesB = "css-9a8fadf3"; + //#endregion + export { aClass, usesB };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-6df9125c { + /* cycle-b */ + &.css-d4dd33b6 { + color: green; + } + }.css-d4dd33b6 { + /* cycle-a */ + color: red; + } + + .css-9a8fadf3 { + &.css-6df9125c { + color: blue; + } + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('sibling modules transformed concurrently resolve each other’s classes', async () => { + const fixturePath = './test/fixtures/siblings.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // The consumer resolves the producer's class while the producer may still + // be mid-transform — the load must be awaited (no cycle), never skipped. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/siblings.input.ts + var usesBoth = "css-fe2dd194"; + //#endregion + export { usesBoth };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-1ad5d685 { + /* producer */ + margin: 4px; + }.css-e587a2cb { + /* consumer */ + &.css-1ad5d685 { + color: blue; + } + }.css-fe2dd194 { + /* uses-both */ + &.css-1ad5d685 { + color: red; + } + + &.css-e587a2cb { + color: green; + } + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('same-file class references: forward references resolve, failed extractions warn', async () => { + const fixturePath = './test/fixtures/same-file-classes.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // `usesForward` references a class declared later in the file (resolves via + // deferred retry); `usesBrokenSameFile` references a class whose extraction + // failed and must warn instead of inlining a phantom class. + expect(result.js).toMatchInlineSnapshot(` + "//#region index.js + function css() { + throw new Error("css\`\` should have been transformed by the ecij plugin"); + } + //#endregion + //#region test/fixtures/same-file-classes.input.ts + function dynamicPad() { + return 4; + } + var usesForward = "css-8432a53b"; + var forwardClass = "css-af69c7f2"; + var usesBrokenSameFile = css\` + &.\${css\` + padding: \${dynamicPad()}px; + \`} { + color: red; + } + \`; + //#endregion + export { forwardClass, usesBrokenSameFile, usesForward };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-8432a53b { + &.css-af69c7f2 { + color: red; + } + } + + .css-af69c7f2 { + /* forward */ + color: green; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(` + [ + { + "code": "PLUGIN_WARNING", + "frame": "14: \`; + 15: const brokenSameFile = css\` + 16: padding: \${dynamicPad()}px; + ^ + 17: \`; + 18: export const usesBrokenSameFile = css\`", + "hook": "transform", + "id": "test/fixtures/same-file-classes.input.ts", + "loc": { + "column": 13, + "file": "test/fixtures/same-file-classes.input.ts", + "line": 16, + }, + "message": "skipped CSS extraction — interpolation is not a static string, number, or identifier", + "plugin": "ecij", + "pluginCode": "COMPLEX_INTERPOLATION", + "pos": 291, + }, + { + "code": "PLUGIN_WARNING", + "frame": "17: \`; + 18: export const usesBrokenSameFile = css\` + 19: &.\${brokenSameFile} { + ^ + 20: color: red; + 21: }", + "hook": "transform", + "id": "test/fixtures/same-file-classes.input.ts", + "loc": { + "column": 6, + "file": "test/fixtures/same-file-classes.input.ts", + "line": 19, + }, + "message": "skipped CSS extraction — could not resolve "brokenSameFile" to a static string or number", + "plugin": "ecij", + "pluginCode": "UNRESOLVED_INTERPOLATION", + "pos": 356, + }, + ] + `); +}); + +test('failed `export *` probes do not drag their stylesheets in', async () => { + const fixturePath = './test/fixtures/barrel-deep-only.input.ts'; + const result = await buildWithPlugin(fixturePath); + + // Resolving `dColor` probes barrel-a/barrel-b without finding it — their + // CSS (/* a */, /* b */) must not appear in the output. + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/barrel-deep-only.input.ts + var usesOnlyDeep = "css-ec484d9a"; + //#endregion + export { usesOnlyDeep };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-ec484d9a { + /* uses-only-deep */ + color: darkcyan; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + +test('aliased css tag import (`import { css as styled }`)', async () => { + const fixturePath = './test/fixtures/tag-alias.input.ts'; + const result = await buildWithPlugin(fixturePath); + + expect(result.js).toMatchInlineSnapshot(` + "//#region test/fixtures/tag-alias.input.ts + var aliasedTagClass = "css-6536678a"; + //#endregion + export { aliasedTagClass };" + `); + expect(result.css).toMatchInlineSnapshot(` + ".css-6536678a { + /* aliased-tag */ + color: olive; + }/*$vite$:1*/" + `); + expect(result.logs).toMatchInlineSnapshot(`[]`); +}); + test('classPrefix setting', async () => { const fixturePath = './test/fixtures/basic.input.ts'; const result = await buildWithPlugin(fixturePath, { diff --git a/vitest.config.ts b/vitest.config.ts index 20666f6..7cce396 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,6 @@ export default defineConfig({ sequence: { shuffle: true, }, - printConsoleTrace: true, + printConsoleTrace: false, }, });