Read OSS

AST-Level Code Transformation: How shadcn/ui Adapts Components to Your Project

Advanced

Prerequisites

  • Article 1: architecture-overview
  • Article 2: registry-system-and-dependency-resolution
  • Basic understanding of AST (Abstract Syntax Tree) manipulation
  • Familiarity with PostCSS and Tailwind CSS

AST-Level Code Transformation: How shadcn/ui Adapts Components to Your Project

The registry protocol from Part 2 delivers raw component source code. But that code is authored with canonical import paths (@/registry/new-york/ui/...), generic icon placeholders, and abstract cn-* CSS classes. Before it lands in your project, it passes through a multi-stage transformer pipeline that rewrites the AST to match your specific configuration. This article dissects each transformer, the style map system that bridges abstract and concrete CSS, and the file writing subsystem that handles the last mile.

The Transformer Pipeline Architecture

The pipeline is orchestrated by packages/shadcn/src/utils/transformers/index.ts. The transform function accepts raw source and an array of transformer functions:

export async function transform(
  opts: TransformOpts,
  transformers: Transformer[] = [
    transformImport,
    transformRsc,
    transformCssVars,
    transformTwPrefixes,
    transformRtl,
    transformIcons,
    transformCleanup,
  ]
) {

Each transformer is a function that receives a ts-morph SourceFile and returns it (possibly modified). They run sequentially, sharing the same SourceFile instance. The actual install path in update-files.ts uses an extended pipeline with additional transforms:

sequenceDiagram
    participant Raw as Raw Source
    participant TS as ts-morph Project
    participant T1 as transformImport
    participant T2 as transformRsc
    participant T3 as transformCssVars
    participant T4 as transformTwPrefixes
    participant T5 as transformIcons
    participant T6 as transformMenu
    participant T7 as transformAsChild
    participant T8 as transformRtl
    participant T9 as transformFont
    participant T10 as transformCleanup
    participant JSX as transformJsx (optional)

    Raw->>TS: Create SourceFile
    TS->>T1: Rewrite imports
    T1->>T2: Add/remove "use client"
    T2->>T3: CSS variable transforms
    T3->>T4: Tailwind prefix
    T4->>T5: Icon library swap
    T5->>T6: Menu transforms
    T6->>T7: asChild patterns
    T7->>T8: RTL transforms
    T8->>T9: Font transforms
    T9->>T10: Remove unused imports
    T10->>JSX: Strip TypeScript (if tsx: false)

The pipeline creates a temporary SourceFile via project.createSourceFile(tempFile, opts.raw, { scriptKind: ScriptKind.TSX }). Using ScriptKind.TSX means TypeScript's parser handles both TypeScript and JSX syntax regardless of the actual file extension.

Import Rewriting

The transformImport transformer rewrites import paths from the canonical registry format to your project's alias configuration. The canonical form uses @/registry/<style>/ui/, @/registry/<style>/lib/, etc.

The updateImportAliases function maps each pattern:

Registry Pattern Config Key Example Output
@/registry/*/ui/ aliases.ui @/components/ui/
@/registry/*/components/ aliases.components @/components/
@/registry/*/lib/ aliases.lib @/lib/
@/registry/*/hooks/ aliases.hooks @/hooks/

There's special handling for the cn utility import. If the import resolves to @/lib/utils and contains a named import of cn, the path is rewritten to the user's configured aliases.utils value (lines 29-47). This ensures the ubiquitous cn() call always resolves correctly regardless of your project structure.

flowchart TD
    A["import path starts with @/registry/"] --> B{Matches /ui/?}
    B -->|Yes| C["Replace with aliases.ui"]
    B -->|No| D{Matches /lib/?}
    D -->|Yes| E["Replace with aliases.lib"]
    D -->|No| F{Matches /hooks/?}
    F -->|Yes| G["Replace with aliases.hooks"]
    F -->|No| H["Replace with aliases.components"]
    I["import from @/lib/utils with cn"] --> J["Replace with aliases.utils"]

RSC Directive and Icon Transforms

The transformRsc transformer is refreshingly simple. If config.rsc is true, it's a no-op — components keep their "use client" directive. If false, it removes the directive by finding the first ExpressionStatement matching /^["']use client["']$/ and calling first.remove().

The icon transformer at transformIcons is more involved. Components use <IconPlaceholder> elements with library-specific props:

<IconPlaceholder lucide="ChevronDown" tabler="IconChevronDown" />

The transformer walks all JsxSelfClosingElement nodes looking for IconPlaceholder tags. For the target library (from config.iconLibrary), it extracts the icon name, removes all library-specific props, replaces the element tag name, and adds the appropriate import. The existing IconPlaceholder import is cleaned up. This lets a single source file support Lucide, Tabler, Phosphor, and Hugeicons without code duplication.

Tip: When authoring components for a custom registry, use <IconPlaceholder> with props for each icon library you want to support. The CLI will swap in the correct icons based on the user's iconLibrary config.

The Style Map System: cn-* Classes to Tailwind Utilities

This is the most architecturally interesting part of the transformer system. Components are authored with abstract CSS class names like cn-button, cn-accordion-trigger, and cn-alert-variant-destructive. These cn-* classes have no inherent styling — they're resolved at install time to concrete Tailwind utility classes based on the selected style.

The createStyleMap function builds the mapping by parsing a style CSS file with PostCSS:

/* From style-nova.css */
.cn-accordion-trigger {
  @apply focus-visible:ring-ring/50 rounded-lg py-2.5 text-sm font-medium;
}

PostCSS walks all rules, extracting @apply directives. For each selector containing a cn-* class, it maps the class name to the applied Tailwind utilities. The result is a plain object:

{
  "cn-accordion-trigger": "focus-visible:ring-ring/50 rounded-lg py-2.5 text-sm font-medium",
  "cn-alert-variant-destructive": "text-destructive bg-card"
}
flowchart TD
    A["style-nova.css"] --> B["PostCSS parse"]
    B --> C["Walk rules"]
    C --> D["Find cn-* selectors"]
    D --> E["Extract @apply values"]
    E --> F["Build StyleMap: cn-class → Tailwind utilities"]
    F --> G["transformStyleMap"]
    G --> H["ts-morph AST walker"]
    H --> I["Find cn-* in cva() and className"]
    I --> J["Replace with Tailwind via tailwind-merge"]

The transformStyleMap function then walks the TypeScript AST looking for cn-* classes in three locations:

  1. cva() calls: Both base strings and variant objects
  2. className JSX attributes: Including nested cn() calls
  3. mergeProps() calls: className properties in object arguments

When a cn-* class is found, it's replaced with the resolved Tailwind utilities using twMerge from tailwind-merge. This ensures that conflicting utilities are properly resolved (e.g., py-2 py-4 becomes py-4).

An allowlist at line 19 preserves certain cn-* classes that serve as CSS selectors rather than style containers: cn-menu-target, cn-menu-translucent, cn-logical-sides, cn-rtl-flip, cn-font-heading. These are used by other transforms or runtime CSS.

File Path Resolution and Writing

The updateFiles function handles the last mile: writing transformed source to disk. The resolveFilePath function determines where each file goes based on its type:

flowchart TD
    A["resolveFilePath(file)"] --> B{Has custom --path?}
    B -->|Yes| C["Use custom path"]
    B -->|No| D{Has file.target?}
    D -->|Yes| E["Resolve target with src/ handling"]
    D -->|No| F{file.type?}
    F -->|registry:ui| G["config.resolvedPaths.ui"]
    F -->|registry:lib| H["config.resolvedPaths.lib"]
    F -->|registry:hook| I["config.resolvedPaths.hooks"]
    F -->|registry:block| J["config.resolvedPaths.components"]
    F -->|default| J

The function handles several edge cases:

  • .env files: Merged with existing content rather than overwritten, preserving existing environment variables
  • Content diffing: Files with identical content (ignoring import differences in workspaces) are skipped
  • Overwrite prompts: Existing files trigger an interactive confirmation unless --overwrite is passed
  • TypeScript to JavaScript: When tsx: false, .tsx.jsx and .ts.js extension mapping happens here
  • Next.js 16 middleware: Files named middleware.ts are renamed to proxy.ts for Next.js 16+ compatibility

Tip: The registry:file and registry:item types skip all transformers — their content is written verbatim. Use these types for framework-agnostic files that shouldn't have their imports or styles rewritten.

TypeScript to JavaScript Conversion

When a project has tsx: false, the optional transformJsx pass runs after all other transformers. It uses Babel's @babel/plugin-transform-typescript to strip type annotations while preserving runtime code. This happens at the very end to ensure all previous transformers work with TypeScript syntax, simplifying their implementation — they never need to handle both TS and JS code paths.

The approach is a deliberate trade-off: the Babel pass adds build time but means every transformer only needs to understand one syntax. If transformers had to handle JavaScript syntax too, every regex pattern and AST walker would need two code paths.

What's Next

We've seen how source code is adapted from canonical form to project-specific form. But where does that canonical form come from? In Part 4, we'll trace the build pipeline that runs in the apps/v4 workspace — how authored components in two base libraries (Radix and Base UI) are combined with six visual styles to produce the static JSON API at ui.shadcn.com/r/.