@tailwind-merge/next 0.0.0-dev.50b1d1e9f69ac68604be38dabea94cccdc6b070a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ //#region src/options.d.ts
2
+ interface TailwindMergeOptions {
3
+ /** Path to the project's Tailwind CSS entrypoint, relative to the project directory. When omitted, the entrypoint is auto-detected within the project. Set this to disambiguate themes or select an entrypoint outside the discovery scan. */
4
+ css?: string;
5
+ /** LRU cache size of the generated `twMerge`, passed through to the generated config. Defaults to tailwind-merge's default. */
6
+ cacheSize?: number;
7
+ /** How theme scales are encoded in the generated config: `'compact'` (default) picks the smallest matcher even when it accepts names beyond the theme, `'exact'` enumerates finite names to avoid that overmatching, at a size cost; arbitrary-value types remain approximate. See the configurator's docs for the tradeoff. */
8
+ encoding?: 'compact' | 'exact';
9
+ /**
10
+ * Prunes the generated config to the classes found in your sources — the same files Tailwind scans, found the same way — so production bundles ship only the class groups and scale values the project uses. Lists composed of scanned candidates merge exactly like with the full generated config; retained validators may also match unscanned names.
11
+ *
12
+ * `true` (the default): prune in `next build`, serve the full config in `next dev`. `false`: never prune — for projects whose class names reach `twMerge` from outside the scanned sources *and* get their styles from somewhere else than this Tailwind build (server-delivered markup, module federation). The object form configures the details.
13
+ */
14
+ prune?: boolean | PruneOptions;
15
+ }
16
+ interface PruneOptions {
17
+ /** Prune production builds. Defaults to `true`. */
18
+ build?: boolean;
19
+ /** Also prune in the dev server, for debugging differences between dev and build: every source edit that changes the used classes then regenerates the module. Defaults to `false`. */
20
+ dev?: boolean;
21
+ /** Log one line per generation saying what pruning did. Defaults to `true`. */
22
+ log?: boolean;
23
+ }
24
+ /**
25
+ * What the loader receives from the plugin through the bundler configuration. Plain data on purpose — Turbopack serializes loader options for its Rust side, so nothing here may be a function, a class instance, or `undefined` (absent keys are simply left out, see `serializableLoaderOptions`). The plugin resolves the user's options against the mode once so the loader never has to know the option surface.
26
+ */
27
+ type LoaderOptions = {
28
+ /** `dev` for the dev server, `build` for `next build`: decides pruning and whether a generation failure fails the compilation (build) or falls back to the last good module (dev). */
29
+ mode: 'dev' | 'build';
30
+ prune: boolean;
31
+ log: boolean;
32
+ css?: string;
33
+ cacheSize?: number;
34
+ encoding?: 'compact' | 'exact';
35
+ };
36
+ //#endregion
37
+ export { PruneOptions as n, TailwindMergeOptions as r, LoaderOptions as t };
38
+ //# sourceMappingURL=options-B1iOW6tJ.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options-B1iOW6tJ.d.mts","names":[],"sources":["../src/options.ts"],"mappings":";UAAiB;;EAEb;;EAEA;;EAEA;;;;;;EAMA,kBAAkB;;UAGL;;EAEb;;EAEA;;EAEA;;;;;KAMQ;;EAER;EACA;EACA;EACA;EACA;EACA"}
@@ -0,0 +1,2 @@
1
+ import { ClassNameValue, ClassValidator, Config, ConfigExtension, createTailwindMerge, extendTailwindMerge, getDefaultConfig as getConfig, mergeConfigs, twJoin, twMerge, validators } from "tailwind-merge";
2
+ export { type ClassNameValue, type ClassValidator, type Config, type ConfigExtension, createTailwindMerge, extendTailwindMerge, getConfig, mergeConfigs, twJoin, twMerge, validators };
@@ -0,0 +1,7 @@
1
+ import { createTailwindMerge, extendTailwindMerge, getDefaultConfig as getConfig, mergeConfigs, twJoin, twMerge, validators } from "tailwind-merge";
2
+ //#region src/runtime.ts
3
+ /*! @tailwind-merge/next runtime fallback */
4
+ //#endregion
5
+ export { createTailwindMerge, extendTailwindMerge, getConfig, mergeConfigs, twJoin, twMerge, validators };
6
+
7
+ //# sourceMappingURL=runtime.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.mjs","names":[],"sources":["../src/runtime.ts"],"sourcesContent":["/*! @tailwind-merge/next runtime fallback */\n\n/**\n * The stable import surface of `@tailwind-merge/next`: `import { twMerge } from '@tailwind-merge/next/runtime'`.\n *\n * When the plugin is configured, neither Turbopack nor webpack ever evaluates this file's content: a loader rule for the built `runtime.mjs` (matched by file name and by the legal comment above, which survives the package build where ordinary comments do not — kept to a few bytes because a bundle that includes this fallback keeps legal comments too) replaces it with the module generated from the project's Tailwind CSS, exporting the same names with the project-specific config in place. This file is what resolves everywhere else (Jest, plain Node scripts, tooling that does not load next.config) and serves tailwind-merge's default behavior, so code using the subpath keeps working outside the Next.js build, just without project-specific precision.\n *\n * It also defines the types users see: TypeScript always resolves the subpath to this file through ordinary package resolution, never to the generated module. The export surface must therefore stay in sync with the generated module's runtime appendix in the plugin core (see `fallbackModuleCode` there and the surface test in tests/config.test.ts).\n *\n * `extendTailwindMerge` deserves a note: in the generated module it extends the project's generated config, which is the reading users expect when customizing. Here it falls back to tailwind-merge's own export, which extends the default config — consistent, since the default config is exactly what this fallback serves. `fromTheme` is deliberately absent from the surface: generated configs materialize theme scales inline and carry an empty `theme` object, so theme getters would never match anything.\n */\nexport {\n createTailwindMerge,\n extendTailwindMerge,\n getDefaultConfig as getConfig,\n mergeConfigs,\n twJoin,\n twMerge,\n validators,\n} from 'tailwind-merge'\nexport type { ClassNameValue, ClassValidator, Config, ConfigExtension } from 'tailwind-merge'\n"],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@tailwind-merge/next",
3
+ "version": "0.0.0-dev.50b1d1e9f69ac68604be38dabea94cccdc6b070a",
4
+ "description": "Next.js plugin that generates a project-optimized twMerge from the project's Tailwind CSS.",
5
+ "license": "MIT",
6
+ "author": "Dany Castillo",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dcastil/tailwind-merge.git",
10
+ "directory": "packages/next"
11
+ },
12
+ "homepage": "https://github.com/dcastil/tailwind-merge",
13
+ "bugs": {
14
+ "url": "https://github.com/dcastil/tailwind-merge/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": [
18
+ "./dist/runtime.mjs"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "default": "./dist/index.mjs"
24
+ },
25
+ "./runtime": {
26
+ "types": "./dist/runtime.d.mts",
27
+ "default": "./dist/runtime.mjs"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "peerDependencies": {
39
+ "@tailwindcss/postcss": "~4.3.3",
40
+ "next": "^16.0.0",
41
+ "tailwindcss": "~4.3.3"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@tailwindcss/postcss": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "dependencies": {
49
+ "@tailwindcss/node": "~4.3.3",
50
+ "@tailwindcss/oxide": "~4.3.3",
51
+ "enhanced-resolve": "^5.24.5",
52
+ "postcss": "^8.5.15",
53
+ "tailwind-merge": "3.7.0-dev.50b1d1e9f69ac68604be38dabea94cccdc6b070a"
54
+ },
55
+ "devDependencies": {
56
+ "@tailwindcss/postcss": "~4.3.3",
57
+ "@types/node": "^24.13.3",
58
+ "eslint": "^9.39.5",
59
+ "next": "^16.3.3",
60
+ "react": "^19.2.0",
61
+ "react-dom": "^19.2.0",
62
+ "tailwindcss": "~4.3.3",
63
+ "tsdown": "^0.22.14",
64
+ "typescript": "^6.0.3",
65
+ "vitest": "^4.1.10",
66
+ "@tailwind-merge/configurator": "0.0.0",
67
+ "@tailwind-merge/plugin-core": "0.0.0"
68
+ },
69
+ "scripts": {
70
+ "build": "tsdown",
71
+ "lint": "eslint --max-warnings 0 .",
72
+ "test": "vitest --no-watch",
73
+ "test:watch": "vitest",
74
+ "test:types": "tsc --noEmit",
75
+ "test:exports": "node scripts/test-packed-package.mjs",
76
+ "test:library-release": "node ../../scripts/check-library-release.mjs --sources ../configurator/src ../plugin-core/src src",
77
+ "release": "pnpm version --tag-version-prefix=@tailwind-merge/next@ --message=@tailwind-merge/next@%s",
78
+ "version": "node ../../scripts/update-pinned-links.mjs && node ../../scripts/pin-readme-links.mjs"
79
+ }
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import type { NextConfig } from 'next'
6
+
7
+ import {
8
+ type TailwindMergeOptions,
9
+ resolveLoaderOptions,
10
+ serializableLoaderOptions,
11
+ } from './options'
12
+
13
+ export type { PruneOptions, TailwindMergeOptions } from './options'
14
+
15
+ /** The function form of `next.config`, which Next.js calls with the current phase (see `next/constants`) and its default config. */
16
+ export type NextConfigFunction = (
17
+ phase: string,
18
+ context: { defaultConfig: NextConfig },
19
+ ) => NextConfig | Promise<NextConfig>
20
+
21
+ /**
22
+ * Configures tailwind-merge for the project's own Tailwind CSS in a Next.js app.
23
+ *
24
+ * Wrap the Next.js config with it and import from the runtime subpath: `import { twMerge } from '@tailwind-merge/next/runtime'`. While Next.js compiles the app, that import resolves to the module generated from the project's Tailwind theme by the inlined plugin core and configurator; outside Next.js it resolves to the real runtime.ts and serves default tailwind-merge behavior. Repository integration goals and invariants live in agents/next-plugin.md.
25
+ *
26
+ * Mechanics: neither Turbopack nor webpack offers virtual modules through the Next.js config, but both run webpack-style loaders, so the plugin registers a loader rule for this package's own `runtime.mjs` with both bundlers and lets the loader (src/loader.ts) replace the file's content with the generated module. The rules are keyed on the file name plus the legal comment inside the file, not on a path in node_modules, because Turbopack matches real paths — a symlinked install (`pnpm link`, a workspace) would otherwise fall through to the default config unnoticed. Dev and build get separate Turbopack rules through the built-in `development`/`production` conditions; the webpack hook reads the mode from its context. `transpilePackages` keeps the Pages Router's server bundle from externalizing the package: an externalized runtime would `require()` the on-disk fallback at request time while the client bundle carries the generated module, and the two would merge classes differently.
27
+ *
28
+ * The dev loop is deliberately quiet: the loader registers the files of the CSS configuration graph as dependencies (never the app's sources, unless `prune.dev` asks for it), so Next.js re-runs it only when one of those changes, and a regeneration that produces identical code leaves the bundlers' module hashes unchanged. Production builds additionally prune the config to the classes found in the project's sources (`prune` option).
29
+ */
30
+ export function withTailwindMerge(
31
+ nextConfig: NextConfigFunction,
32
+ options?: TailwindMergeOptions,
33
+ ): NextConfigFunction
34
+ export function withTailwindMerge(
35
+ nextConfig?: NextConfig,
36
+ options?: TailwindMergeOptions,
37
+ ): NextConfig
38
+ export function withTailwindMerge(
39
+ nextConfig: NextConfig | NextConfigFunction = {},
40
+ options: TailwindMergeOptions = {},
41
+ ): NextConfig | NextConfigFunction {
42
+ if (typeof nextConfig === 'function') {
43
+ return async (phase, context) => applyToConfig(await nextConfig(phase, context), options)
44
+ }
45
+ return applyToConfig(nextConfig, options)
46
+ }
47
+
48
+ type TurbopackRules = NonNullable<NonNullable<NextConfig['turbopack']>['rules']>
49
+ type TurbopackRuleCollection = TurbopackRules[string]
50
+ type TurbopackRuleItem = Exclude<TurbopackRuleCollection, readonly unknown[]>
51
+
52
+ function applyToConfig(config: NextConfig, options: TailwindMergeOptions): NextConfig {
53
+ warnWithoutCompilerConfig()
54
+ const rules = config.turbopack?.rules ?? {}
55
+ const existing = rules[RULE_GLOB]
56
+ const existingItems =
57
+ existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]
58
+ const transpilePackages = config.transpilePackages ?? []
59
+
60
+ return {
61
+ ...config,
62
+ transpilePackages: transpilePackages.includes(PACKAGE_NAME)
63
+ ? transpilePackages
64
+ : [...transpilePackages, PACKAGE_NAME],
65
+ turbopack: {
66
+ ...config.turbopack,
67
+ rules: {
68
+ ...rules,
69
+ // A user rule on the same glob keeps running; rules are evaluated in order, so the plugin's come last.
70
+ [RULE_GLOB]: [...existingItems, ...turbopackRules(options)],
71
+ },
72
+ },
73
+ webpack(webpackConfig, context) {
74
+ webpackConfig.module.rules.push({
75
+ test: isRuntimeModule,
76
+ use: [
77
+ {
78
+ loader: LOADER_PATH,
79
+ options: serializableLoaderOptions(
80
+ resolveLoaderOptions(options, context.dev ? 'dev' : 'build'),
81
+ ),
82
+ },
83
+ ],
84
+ })
85
+ return config.webpack ? config.webpack(webpackConfig, context) : webpackConfig
86
+ },
87
+ }
88
+ }
89
+
90
+ let warnedWithoutCompilerConfig = false
91
+
92
+ /**
93
+ * Turbopack applies loader rules to code in node_modules only when the project has a `tsconfig.json` or `jsconfig.json` — even an empty one; the content does not matter (observed with Next.js 16.3, reproduced with a bare `*.mjs` rule). Without one, the runtime subpath would silently keep tailwind-merge's default behavior under Turbopack, so say so once per process. create-next-app writes one of the two files for every project, which keeps this rare. The working directory stands in for the project directory, which the config API does not expose.
94
+ */
95
+ function warnWithoutCompilerConfig() {
96
+ if (warnedWithoutCompilerConfig) {
97
+ return
98
+ }
99
+ warnedWithoutCompilerConfig = true
100
+ const directory = process.cwd()
101
+ if (
102
+ !['tsconfig.json', 'jsconfig.json'].some((file) => existsSync(path.join(directory, file)))
103
+ ) {
104
+ console.warn(
105
+ `[${PACKAGE_NAME}] No tsconfig.json or jsconfig.json found in ${directory}. Turbopack only applies the plugin's loader to installed packages when the project has one, so without it the runtime import keeps tailwind-merge's default configuration. Add an empty jsconfig.json if the project has no TypeScript configuration.`,
106
+ )
107
+ }
108
+ }
109
+
110
+ /** One rule per mode: Turbopack evaluates the plugin's config once for `next dev` and `next build` alike, so the mode has to be a rule condition rather than a value computed at config time. */
111
+ function turbopackRules(options: TailwindMergeOptions): TurbopackRuleItem[] {
112
+ return (['dev', 'build'] as const).map((mode) => ({
113
+ condition: {
114
+ all: [
115
+ { path: RUNTIME_PATH_PATTERN },
116
+ { content: RUNTIME_MARKER_PATTERN },
117
+ mode === 'dev' ? 'development' : 'production',
118
+ ],
119
+ },
120
+ loaders: [
121
+ {
122
+ loader: LOADER_PATH,
123
+ options: serializableLoaderOptions(resolveLoaderOptions(options, mode)),
124
+ },
125
+ ],
126
+ }))
127
+ }
128
+
129
+ /** webpack hands rules the resolved resource path, with symlinks resolved by default: compare against both spellings of this package's runtime file. */
130
+ function isRuntimeModule(file: string): boolean {
131
+ return file === RUNTIME_PATH || file === runtimeRealPath()
132
+ }
133
+
134
+ let resolvedRuntimeRealPath: string | undefined
135
+
136
+ function runtimeRealPath(): string {
137
+ if (resolvedRuntimeRealPath === undefined) {
138
+ try {
139
+ resolvedRuntimeRealPath = realpathSync(RUNTIME_PATH)
140
+ } catch {
141
+ // The package's own runtime file is missing only in unbuilt source checkouts; the unresolved path keeps the comparison meaningful.
142
+ resolvedRuntimeRealPath = RUNTIME_PATH
143
+ }
144
+ }
145
+ return resolvedRuntimeRealPath
146
+ }
147
+
148
+ const PACKAGE_NAME = '@tailwind-merge/next'
149
+
150
+ /** Turbopack rule keys are file-name globs; the conditions below narrow the rule to this package's runtime file. */
151
+ const RULE_GLOB = '*.mjs'
152
+
153
+ /** Turbopack matches project-relative real paths with forward slashes; only the file name is fixed across install layouts. */
154
+ const RUNTIME_PATH_PATTERN = /(^|\/)runtime\.mjs$/
155
+
156
+ /** The legal comment at the top of src/runtime.ts, which the build preserves — the one part of the runtime file's content the rule can rely on across install layouts. */
157
+ const RUNTIME_MARKER_PATTERN = /@tailwind-merge\/next runtime fallback/
158
+
159
+ /** Both bundlers accept a loader as an absolute file path, which sidesteps loader-name resolution and the exports map. Built and source layouts agree on the relative location. */
160
+ const LOADER_PATH = fileURLToPath(new URL('./loader.mjs', import.meta.url))
161
+
162
+ const RUNTIME_PATH = fileURLToPath(new URL('./runtime.mjs', import.meta.url))
package/src/loader.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ type GeneratedRuntimeModule,
6
+ type GenerationSession,
7
+ createGenerationSession,
8
+ discoverCssRoot,
9
+ fallbackModuleCode,
10
+ } from '@tailwind-merge/plugin-core'
11
+
12
+ import { type LoaderOptions } from './options'
13
+
14
+ /** The slice of the webpack loader API this loader uses. Turbopack's loader runner provides the same members (verified against Next.js 16.3, where `addMissingDependency` is accepted but not acted on — the recovery directories the plugin core reports cover that case as context dependencies). */
15
+ export interface LoaderContext {
16
+ /** The Next.js project directory. */
17
+ rootContext: string
18
+ resourcePath: string
19
+ getOptions(): LoaderOptions
20
+ addDependency(file: string): void
21
+ addContextDependency(directory: string): void
22
+ addMissingDependency(file: string): void
23
+ }
24
+
25
+ /**
26
+ * Replaces the content of this package's runtime module with the module generated from the project's Tailwind CSS. Registered by `withTailwindMerge` (src/index.ts) with both Turbopack and webpack; runs once per compilation environment (server components, server rendering, browser), which the per-process project state below turns into one generation.
27
+ *
28
+ * Every run registers the CSS configuration graph — entrypoint, imported stylesheets, `@config`/`@plugin` modules, and the directories a missing import would appear in — as dependencies, so the bundler re-runs the loader when any of them changes and `refresh()` decides whether that change regenerates the module. A regeneration with identical output changes nothing downstream: both bundlers hash module content. With pruning active (builds, or dev with `prune.dev`), the scanned source directories are context dependencies too, so a class-usage change re-prunes the retained classification, and Turbopack's persistent cache cannot serve a module pruned for other sources.
29
+ *
30
+ * Failure policy mirrors the Vite plugin's: a build fails on a configuration-generation failure (the loader rejects, which fails the compilation), while the dev server logs the error and keeps serving the last good module — or the default fallback before the first success. A project without a Tailwind root warns once and serves the default fallback as well. A failed source scan is not a generation failure: the module holds the full config and the warning says so.
31
+ */
32
+ export default async function tailwindMergeLoader(this: LoaderContext): Promise<string> {
33
+ const options = this.getOptions()
34
+ const project = projectState(this.rootContext, options)
35
+ const generated = await project.next()
36
+ registerDependencies(this, project, generated)
37
+ // Without a Tailwind root (or before the first successful generation) the served module is the same default-config surface the file on disk provides, minus the file's marker comment, which would otherwise survive minification in the app bundle.
38
+ return generated?.code ?? fallbackModuleCode(IMPORT_SOURCE)
39
+ }
40
+
41
+ interface ProjectState {
42
+ root: string
43
+ session: GenerationSession
44
+ /** The resolved entrypoint once discovery has finished: null when the project has none, undefined while unknown. */
45
+ cssRoot: string | null | undefined
46
+ /** Coalesces the compilation environments' concurrent runs into one generation or refresh. */
47
+ inFlight: Promise<GeneratedRuntimeModule | null> | undefined
48
+ started: boolean
49
+ next(): Promise<GeneratedRuntimeModule | null>
50
+ }
51
+
52
+ /** One state per project and option set for the life of the loader process — Next.js runs webpack loaders in its own process and Turbopack loaders in a pool process it keeps alive across compilations, so the session's retained classification and dependency graph survive from one loader run to the next. */
53
+ const projects = new Map<string, ProjectState>()
54
+
55
+ function projectState(root: string, options: LoaderOptions): ProjectState {
56
+ const key = `${root}\0${JSON.stringify(options)}`
57
+ let state = projects.get(key)
58
+ if (state) {
59
+ return state
60
+ }
61
+
62
+ const cssRoot: Promise<string | null> =
63
+ options.css !== undefined
64
+ ? Promise.resolve(path.resolve(root, options.css))
65
+ : discoverCssRoot(root, { packageName: PACKAGE_NAME }).then((found) => {
66
+ if (found === null) {
67
+ console.warn(
68
+ `[${PACKAGE_NAME}] No Tailwind CSS root found — serving default tailwind-merge behavior. Set the \`css\` option to your Tailwind entrypoint if it uses another extension or is outside the scanned directories.`,
69
+ )
70
+ }
71
+ return found
72
+ })
73
+
74
+ const session = createGenerationSession({
75
+ cssRoot,
76
+ root,
77
+ packageName: PACKAGE_NAME,
78
+ importSource: IMPORT_SOURCE,
79
+ cacheSize: options.cacheSize,
80
+ encoding: options.encoding,
81
+ // Tailwind's PostCSS plugin, which Next.js runs, starts automatic source detection in the working directory — the project directory in any ordinary `next dev`/`next build`.
82
+ prune: options.prune ? { autoDetectBases: [root] } : undefined,
83
+ onGenerated: (generated) => reportPruning(generated, options),
84
+ onError(error, current) {
85
+ if (options.mode === 'build') {
86
+ throw error
87
+ }
88
+ console.error(
89
+ `[${PACKAGE_NAME}] Generating the tailwind-merge config failed${current ? ' — keeping the previous one' : ''}: ${error instanceof Error ? error.message : String(error)}`,
90
+ )
91
+ },
92
+ })
93
+ state = {
94
+ root,
95
+ session,
96
+ cssRoot: undefined,
97
+ inFlight: undefined,
98
+ started: false,
99
+ next() {
100
+ if (!this.inFlight) {
101
+ // The first run is the session's eager generation; every later loader run is a refresh, which reuses the module unless the CSS graph or the used classes changed. A rejection (a build's failure policy) propagates to every waiting run.
102
+ const attempt = this.started ? this.session.refresh() : this.session.generation
103
+ this.started = true
104
+ this.inFlight = attempt.finally(() => {
105
+ this.inFlight = undefined
106
+ })
107
+ }
108
+ return this.inFlight
109
+ },
110
+ }
111
+ void cssRoot.then(
112
+ (found) => {
113
+ state!.cssRoot = found
114
+ },
115
+ () => {},
116
+ )
117
+ projects.set(key, state)
118
+ return state
119
+ }
120
+
121
+ /**
122
+ * Hands the session's dependency graph to the bundler: files as file dependencies, directories (the recovery directories reported for missing imports, and a missing explicit entrypoint's nearest existing parent) as context dependencies, and nonexistent paths as missing dependencies for webpack, which acts on them. With pruning, the scanned source bases join as context dependencies.
123
+ */
124
+ function registerDependencies(
125
+ context: LoaderContext,
126
+ project: ProjectState,
127
+ generated: GeneratedRuntimeModule | null,
128
+ ) {
129
+ const dependencies = new Set(project.session.dependencies)
130
+ if (project.cssRoot) {
131
+ dependencies.add(project.cssRoot)
132
+ }
133
+ for (const dependency of dependencies) {
134
+ const stats = statSync(dependency, { throwIfNoEntry: false })
135
+ if (!stats) {
136
+ context.addMissingDependency(dependency)
137
+ if (dependency === project.cssRoot) {
138
+ context.addContextDependency(nearestExistingDirectory(dependency))
139
+ }
140
+ } else if (stats.isDirectory()) {
141
+ context.addContextDependency(dependency)
142
+ } else {
143
+ context.addDependency(dependency)
144
+ }
145
+ }
146
+ for (const source of generated?.pruning?.scanner.sources ?? []) {
147
+ if (!source.negated) {
148
+ context.addContextDependency(source.base)
149
+ }
150
+ }
151
+ }
152
+
153
+ function nearestExistingDirectory(file: string): string {
154
+ let directory = path.dirname(file)
155
+ while (!statSync(directory, { throwIfNoEntry: false })?.isDirectory()) {
156
+ const parent = path.dirname(directory)
157
+ if (parent === directory) {
158
+ break
159
+ }
160
+ directory = parent
161
+ }
162
+ return directory
163
+ }
164
+
165
+ /** One line per generation about pruning, so the behavior and its effect are visible where someone debugging a production-only merge difference would look; a failed scan is always reported, since the module then silently holds the full config. */
166
+ function reportPruning(generated: GeneratedRuntimeModule, options: LoaderOptions) {
167
+ if (generated.pruningError) {
168
+ console.warn(
169
+ `[${PACKAGE_NAME}] Could not scan your sources, serving the full tailwind-merge config instead: ${generated.pruningError.message}`,
170
+ )
171
+ } else if (generated.pruning && options.log) {
172
+ const { classGroupsAfter, classGroupsBefore, classifiedClassCount } =
173
+ generated.pruning.report
174
+ console.log(
175
+ `[${PACKAGE_NAME}] Pruned the tailwind-merge config to ${classGroupsAfter} of ${classGroupsBefore} class groups from the ${classifiedClassCount} classes found in your sources`,
176
+ )
177
+ }
178
+ }
179
+
180
+ const PACKAGE_NAME = '@tailwind-merge/next'
181
+
182
+ /** The generated module replaces a real file inside this package, so it can import tailwind-merge the ordinary way: resolution starts in the package's own directory, where its dependency is installed under any package manager. */
183
+ const IMPORT_SOURCE = 'tailwind-merge'
package/src/options.ts ADDED
@@ -0,0 +1,81 @@
1
+ export interface TailwindMergeOptions {
2
+ /** Path to the project's Tailwind CSS entrypoint, relative to the project directory. When omitted, the entrypoint is auto-detected within the project. Set this to disambiguate themes or select an entrypoint outside the discovery scan. */
3
+ css?: string
4
+ /** LRU cache size of the generated `twMerge`, passed through to the generated config. Defaults to tailwind-merge's default. */
5
+ cacheSize?: number
6
+ /** How theme scales are encoded in the generated config: `'compact'` (default) picks the smallest matcher even when it accepts names beyond the theme, `'exact'` enumerates finite names to avoid that overmatching, at a size cost; arbitrary-value types remain approximate. See the configurator's docs for the tradeoff. */
7
+ encoding?: 'compact' | 'exact'
8
+ /**
9
+ * Prunes the generated config to the classes found in your sources — the same files Tailwind scans, found the same way — so production bundles ship only the class groups and scale values the project uses. Lists composed of scanned candidates merge exactly like with the full generated config; retained validators may also match unscanned names.
10
+ *
11
+ * `true` (the default): prune in `next build`, serve the full config in `next dev`. `false`: never prune — for projects whose class names reach `twMerge` from outside the scanned sources *and* get their styles from somewhere else than this Tailwind build (server-delivered markup, module federation). The object form configures the details.
12
+ */
13
+ prune?: boolean | PruneOptions
14
+ }
15
+
16
+ export interface PruneOptions {
17
+ /** Prune production builds. Defaults to `true`. */
18
+ build?: boolean
19
+ /** Also prune in the dev server, for debugging differences between dev and build: every source edit that changes the used classes then regenerates the module. Defaults to `false`. */
20
+ dev?: boolean
21
+ /** Log one line per generation saying what pruning did. Defaults to `true`. */
22
+ log?: boolean
23
+ }
24
+
25
+ /**
26
+ * What the loader receives from the plugin through the bundler configuration. Plain data on purpose — Turbopack serializes loader options for its Rust side, so nothing here may be a function, a class instance, or `undefined` (absent keys are simply left out, see `serializableLoaderOptions`). The plugin resolves the user's options against the mode once so the loader never has to know the option surface.
27
+ */
28
+ export type LoaderOptions = {
29
+ /** `dev` for the dev server, `build` for `next build`: decides pruning and whether a generation failure fails the compilation (build) or falls back to the last good module (dev). */
30
+ mode: 'dev' | 'build'
31
+ prune: boolean
32
+ log: boolean
33
+ css?: string
34
+ cacheSize?: number
35
+ encoding?: 'compact' | 'exact'
36
+ }
37
+
38
+ /** Resolves the `prune` option's shorthand forms and defaults into the full object. */
39
+ export function resolvePruneOptions(option: TailwindMergeOptions['prune']): Required<PruneOptions> {
40
+ if (option === false) {
41
+ return { build: false, dev: false, log: false }
42
+ }
43
+ if (option === true || option === undefined) {
44
+ return { build: true, dev: false, log: true }
45
+ }
46
+ return { build: option.build ?? true, dev: option.dev ?? false, log: option.log ?? true }
47
+ }
48
+
49
+ /** The loader options for one mode, with only the keys the user set — the loader applies the defaults of the underlying generator for the rest. */
50
+ export function resolveLoaderOptions(
51
+ options: TailwindMergeOptions,
52
+ mode: LoaderOptions['mode'],
53
+ ): LoaderOptions {
54
+ const prune = resolvePruneOptions(options.prune)
55
+ const resolved: LoaderOptions = {
56
+ mode,
57
+ prune: mode === 'build' ? prune.build : prune.dev,
58
+ log: prune.log,
59
+ }
60
+ if (options.css !== undefined) {
61
+ resolved.css = options.css
62
+ }
63
+ if (options.cacheSize !== undefined) {
64
+ resolved.cacheSize = options.cacheSize
65
+ }
66
+ if (options.encoding !== undefined) {
67
+ resolved.encoding = options.encoding
68
+ }
69
+ return resolved
70
+ }
71
+
72
+ /** The same options as a value the bundler configuration types accept: Next's loader option type has no room for `undefined`, which TypeScript reads into every optional key, so the object is rebuilt from its present keys. */
73
+ export function serializableLoaderOptions(
74
+ options: LoaderOptions,
75
+ ): Record<string, string | number | boolean> {
76
+ return Object.fromEntries(
77
+ Object.entries(options).filter(
78
+ (entry): entry is [string, string | number | boolean] => entry[1] !== undefined,
79
+ ),
80
+ )
81
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,21 @@
1
+ /*! @tailwind-merge/next runtime fallback */
2
+
3
+ /**
4
+ * The stable import surface of `@tailwind-merge/next`: `import { twMerge } from '@tailwind-merge/next/runtime'`.
5
+ *
6
+ * When the plugin is configured, neither Turbopack nor webpack ever evaluates this file's content: a loader rule for the built `runtime.mjs` (matched by file name and by the legal comment above, which survives the package build where ordinary comments do not — kept to a few bytes because a bundle that includes this fallback keeps legal comments too) replaces it with the module generated from the project's Tailwind CSS, exporting the same names with the project-specific config in place. This file is what resolves everywhere else (Jest, plain Node scripts, tooling that does not load next.config) and serves tailwind-merge's default behavior, so code using the subpath keeps working outside the Next.js build, just without project-specific precision.
7
+ *
8
+ * It also defines the types users see: TypeScript always resolves the subpath to this file through ordinary package resolution, never to the generated module. The export surface must therefore stay in sync with the generated module's runtime appendix in the plugin core (see `fallbackModuleCode` there and the surface test in tests/config.test.ts).
9
+ *
10
+ * `extendTailwindMerge` deserves a note: in the generated module it extends the project's generated config, which is the reading users expect when customizing. Here it falls back to tailwind-merge's own export, which extends the default config — consistent, since the default config is exactly what this fallback serves. `fromTheme` is deliberately absent from the surface: generated configs materialize theme scales inline and carry an empty `theme` object, so theme getters would never match anything.
11
+ */
12
+ export {
13
+ createTailwindMerge,
14
+ extendTailwindMerge,
15
+ getDefaultConfig as getConfig,
16
+ mergeConfigs,
17
+ twJoin,
18
+ twMerge,
19
+ validators,
20
+ } from 'tailwind-merge'
21
+ export type { ClassNameValue, ClassValidator, Config, ConfigExtension } from 'tailwind-merge'