@tailwind-merge/vite 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,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,2 @@
1
+ import { createTailwindMerge, extendTailwindMerge, getDefaultConfig as getConfig, mergeConfigs, twJoin, twMerge, validators } from "tailwind-merge";
2
+ export { createTailwindMerge, extendTailwindMerge, getConfig, mergeConfigs, twJoin, twMerge, validators };
@@ -0,0 +1 @@
1
+ export * from "tailwind-merge";
@@ -0,0 +1,2 @@
1
+ export * from "tailwind-merge";
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@tailwind-merge/vite",
3
+ "version": "0.0.0-dev.50b1d1e9f69ac68604be38dabea94cccdc6b070a",
4
+ "description": "Vite 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/vite"
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": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.mts",
21
+ "default": "./dist/index.mjs"
22
+ },
23
+ "./runtime": {
24
+ "types": "./dist/runtime.d.mts",
25
+ "default": "./dist/runtime.mjs"
26
+ },
27
+ "./tailwind-merge": {
28
+ "types": "./dist/tailwind-merge.d.mts",
29
+ "default": "./dist/tailwind-merge.mjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ },
40
+ "peerDependencies": {
41
+ "@tailwindcss/vite": "~4.3.3",
42
+ "tailwindcss": "~4.3.3",
43
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@tailwindcss/vite": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "@tailwindcss/node": "~4.3.3",
52
+ "@tailwindcss/oxide": "~4.3.3",
53
+ "enhanced-resolve": "^5.24.5",
54
+ "postcss": "^8.5.15",
55
+ "tailwind-merge": "3.7.0-dev.50b1d1e9f69ac68604be38dabea94cccdc6b070a"
56
+ },
57
+ "devDependencies": {
58
+ "@tailwindcss/vite": "~4.3.3",
59
+ "@types/node": "^24.13.3",
60
+ "eslint": "^9.39.5",
61
+ "tailwindcss": "~4.3.3",
62
+ "tsdown": "^0.22.14",
63
+ "typescript": "^6.0.3",
64
+ "vite": "^7.3.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/vite@ --message=@tailwind-merge/vite@%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,398 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import type { UsageScan } from '@tailwind-merge/configurator'
5
+ import {
6
+ type GeneratedRuntimeModule,
7
+ createGenerationSession,
8
+ discoverCssRoot,
9
+ fallbackModuleCode,
10
+ hashClasses,
11
+ } from '@tailwind-merge/plugin-core'
12
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
13
+
14
+ import { autoDetectBases, resolvePruneOptions } from './prune-options'
15
+ import { createTailwindIntegration } from './resolution'
16
+ import { type UpdateTrigger, createUpdateScheduler } from './updates'
17
+
18
+ export interface TailwindMergeOptions {
19
+ /** Path to the project's Tailwind CSS entrypoint, relative to the Vite root. When omitted, the entrypoint is auto-detected within the root. Set this to disambiguate themes or select an entrypoint outside the discovery scan. */
20
+ css?: string
21
+ /** LRU cache size of the generated `twMerge`, passed through to the generated config. Defaults to tailwind-merge's default. */
22
+ cacheSize?: number
23
+ /** 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. */
24
+ encoding?: 'compact' | 'exact'
25
+ /**
26
+ * 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.
27
+ *
28
+ * `true` (the default, except in library mode): prune in `vite build`, serve the full config in 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.
29
+ */
30
+ prune?: boolean | PruneOptions
31
+ }
32
+
33
+ export interface PruneOptions {
34
+ /** Prune production builds. Defaults to `true` — except in library mode (`build.lib`), where the consuming app's classes can't be scanned and the default is `false`. */
35
+ build?: boolean
36
+ /** Also prune in the dev server, for debugging differences between dev and build: every source edit that changes the used classes then regenerates and reloads. Defaults to `false`. */
37
+ dev?: boolean
38
+ /** Log one line per generation saying what pruning did. Defaults to `true`. */
39
+ log?: boolean
40
+ }
41
+
42
+ /**
43
+ * What the dev server did in reaction to a file change, reported through `TailwindMergePluginApi.onUpdate` once the plugin has finished processing the change (debounce, regeneration or re-scan, invalidation).
44
+ */
45
+ export interface PluginUpdate {
46
+ /** Which kind of file changed: one of the CSS graph (`'config'`), or — with `prune.dev` — any other watched file, which may be a source (`'sources'`). */
47
+ trigger: UpdateTrigger
48
+ /** Whether a new module was generated. False when a sources change left the used classes unchanged, and when generation failed and the previous module stays in service. */
49
+ regenerated: boolean
50
+ /** Whether the served module changed and a full reload was sent. False when regeneration produced identical output — the stability gate. */
51
+ reloaded: boolean
52
+ }
53
+
54
+ /**
55
+ * The plugin's `api` object (Vite's convention for what a plugin exposes to other plugins and tooling). Lets tooling — and this package's own tests — learn when the dev server has finished reacting to an edit instead of guessing with timeouts.
56
+ */
57
+ export interface TailwindMergePluginApi {
58
+ /** Subscribes to the dev server's reactions to file changes. Returns the unsubscribe function. */
59
+ onUpdate(listener: (update: PluginUpdate) => void): () => void
60
+ }
61
+
62
+ /**
63
+ * Vite plugin that configures tailwind-merge for the project's own Tailwind CSS.
64
+ *
65
+ * Add it next to `@tailwindcss/vite` and import from the runtime subpath: `import { twMerge } from '@tailwind-merge/vite/runtime'`. While Vite runs, that import resolves to an in-memory module generated from the project's Tailwind theme by @tailwind-merge/configurator; outside Vite it resolves to the real runtime.ts and serves default tailwind-merge behavior. Repository integration goals and invariants live in agents/vite-plugin.md.
66
+ *
67
+ * The dev loop is deliberately quiet: generation reads only the CSS configuration (never which classes the app uses, unless `prune.dev` asks for it), regenerates only when a file of the CSS graph changes, and even then triggers a full reload only when the generated module actually changed — editing utility classes in app.css causes no churn. Production builds additionally prune the config to the classes found in the project's sources (`prune` option).
68
+ */
69
+ export default function tailwindMerge(
70
+ options: TailwindMergeOptions = {},
71
+ ): Plugin & { api: TailwindMergePluginApi } {
72
+ let config: ResolvedConfig
73
+ let integration: ReturnType<typeof createTailwindIntegration>
74
+ let devServer: ViteDevServer | undefined
75
+ /** Resolves to the discovered (or configured) CSS entrypoint, null when the project has none; rejects on ambiguity. Resolved before the runtime subpath resolves, so redirect vs. fallback is decided exactly once. */
76
+ let cssRoot: Promise<string | null>
77
+ let session: ReturnType<typeof createGenerationSession>
78
+ let pruneLog = true
79
+ let buildStarts = 0
80
+ const updateListeners = new Set<(update: PluginUpdate) => void>()
81
+ let updates: ReturnType<typeof createUpdateScheduler> | undefined
82
+
83
+ async function locateCssRoot(): Promise<string | null> {
84
+ if (options.css !== undefined) {
85
+ return path.resolve(config.root, options.css)
86
+ }
87
+ const discovered = await discoverCssRoot(config.root, {
88
+ resolveCss: integration.resolveCss,
89
+ packageName: PACKAGE_NAME,
90
+ })
91
+ if (discovered === null) {
92
+ config.logger.warn(
93
+ `[${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.`,
94
+ )
95
+ }
96
+ return discovered
97
+ }
98
+
99
+ /** 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. */
100
+ function reportPruning(generated: GeneratedRuntimeModule) {
101
+ if (generated.pruningError) {
102
+ config.logger.warn(
103
+ `[@tailwind-merge/vite] Could not scan your sources, serving the full tailwind-merge config instead: ${generated.pruningError.message}`,
104
+ )
105
+ } else if (generated.pruning && pruneLog) {
106
+ const { classGroupsAfter, classGroupsBefore, classifiedClassCount } =
107
+ generated.pruning.report
108
+ config.logger.info(
109
+ `[@tailwind-merge/vite] Pruned the tailwind-merge config to ${classGroupsAfter} of ${classGroupsBefore} class groups from the ${classifiedClassCount} classes found in your sources`,
110
+ { timestamp: config.command === 'serve' },
111
+ )
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Watch existing dependencies and recovery directories, retaining missing filenames only in the session's graph. Chokidar on Linux watches missing filenames through competing, target-filtered directory scans: a missing package.json watch can suppress an imported file's creation even when its parent is also watched.
117
+ * Resolvers report recovery directories for missing imports. A missing entrypoint has no resolver, so it requests its nearest existing parent here instead.
118
+ */
119
+ function watchDependency(file: string, recoverMissingEntrypoint = false) {
120
+ if (!devServer) {
121
+ return
122
+ }
123
+ while (!existsSync(file)) {
124
+ const parent = path.dirname(file)
125
+ if (!recoverMissingEntrypoint || parent === file) {
126
+ return
127
+ }
128
+ file = parent
129
+ }
130
+ devServer.watcher.add(file)
131
+ }
132
+
133
+ /** Source directories outside the Vite root need explicit watching too; configuration dependencies are registered as the session discovers them. */
134
+ function watchSourceDirectories(generated: GeneratedRuntimeModule) {
135
+ if (!devServer) {
136
+ return
137
+ }
138
+ const outOfRoot = (filePath: string) => path.relative(config.root, filePath).startsWith('..')
139
+ for (const source of generated.pruning?.scanner.sources ?? []) {
140
+ if (!source.negated && outOfRoot(source.base)) {
141
+ devServer.watcher.add(source.base)
142
+ }
143
+ }
144
+ }
145
+
146
+ /** Swaps the served module in after a regeneration that produced a real change: invalidates it in every environment and asks the browser to reload. Returns whether that happened. */
147
+ function reloadIfChanged(
148
+ generated: GeneratedRuntimeModule | null,
149
+ previousHash: string | undefined,
150
+ ): boolean {
151
+ if (!generated || !devServer) {
152
+ return false
153
+ }
154
+ watchSourceDirectories(generated)
155
+ if (generated.hash === previousHash) {
156
+ // The stability gate: something changed on disk but the generated config didn't (utility edits, comments, formatting, class usage that the config already covers) — nothing to invalidate, no reload.
157
+ return false
158
+ }
159
+ for (const environment of Object.values(devServer.environments)) {
160
+ const module = environment.moduleGraph.getModuleById(VIRTUAL_MODULE_ID)
161
+ if (module) {
162
+ environment.moduleGraph.invalidateModule(module)
163
+ }
164
+ }
165
+ // Full reload instead of HMR propagation: merged class strings are baked into the rendered DOM, so a hot-swapped twMerge cannot fix what is already on screen — the same reasoning as @tailwindcss/vite's full reloads for scanned files.
166
+ try {
167
+ devServer.ws.send({ type: 'full-reload' })
168
+ } catch {
169
+ // HMR can be disabled (tests, middleware mode setups); the module-graph invalidation above is what matters then.
170
+ }
171
+ config.logger.info('[@tailwind-merge/vite] tailwind-merge config changed — reloading', {
172
+ timestamp: true,
173
+ })
174
+ return true
175
+ }
176
+
177
+ function notifyUpdate(update: PluginUpdate) {
178
+ for (const listener of updateListeners) {
179
+ listener(update)
180
+ }
181
+ }
182
+
183
+ /** Runs both kinds of dev update through one invalidation path: a CSS edit regenerates, a source-only edit re-scans and re-prunes the retained classification. The scheduler keeps this path serial. */
184
+ async function updateAndReload(trigger: UpdateTrigger) {
185
+ const previous = await session.generation
186
+ const cssPath = await cssRoot
187
+ if (cssPath === null) {
188
+ return
189
+ }
190
+ if (trigger === 'sources') {
191
+ const pruning = previous?.pruning
192
+ if (!pruning) {
193
+ return
194
+ }
195
+ let scan: UsageScan
196
+ try {
197
+ scan = pruning.scanner.scan()
198
+ } catch (error) {
199
+ config.logger.warn(
200
+ `[@tailwind-merge/vite] Re-scanning your sources failed, keeping the current tailwind-merge config: ${error instanceof Error ? error.message : String(error)}`,
201
+ )
202
+ notifyUpdate({ trigger, regenerated: false, reloaded: false })
203
+ return
204
+ }
205
+ if (hashClasses(scan.classes) === pruning.classesHash) {
206
+ notifyUpdate({ trigger, regenerated: false, reloaded: false })
207
+ return
208
+ }
209
+ // The scan is handed over: re-pruning reuses the retained classification and must not scan again.
210
+ const repruned = await session.reprune(scan)
211
+ notifyUpdate({
212
+ trigger,
213
+ regenerated: repruned !== previous,
214
+ reloaded: reloadIfChanged(repruned, previous?.hash),
215
+ })
216
+ return
217
+ }
218
+
219
+ const generated = await session.regenerate()
220
+ notifyUpdate({
221
+ trigger,
222
+ regenerated: generated !== previous,
223
+ reloaded: reloadIfChanged(generated, previous?.hash),
224
+ })
225
+ }
226
+
227
+ return {
228
+ name: '@tailwind-merge/vite',
229
+ // The runtime subpath is a real, installable package path, so Vite's own resolver can resolve it. Interception must therefore run before the core plugins — without this, vite:resolve wins and the fallback file gets served everywhere.
230
+ enforce: 'pre',
231
+
232
+ api: {
233
+ onUpdate(listener) {
234
+ updateListeners.add(listener)
235
+ return () => {
236
+ updateListeners.delete(listener)
237
+ }
238
+ },
239
+ },
240
+
241
+ config: () => ({
242
+ // The dep optimizer must not prebundle the runtime subpath, or dev would freeze the on-disk fallback before resolveId can redirect it.
243
+ optimizeDeps: { exclude: [RUNTIME_SPECIFIER] },
244
+ // SSR must see the redirect too, or server and client would merge classes differently and produce hydration-visible class drift.
245
+ ssr: { noExternal: ['@tailwind-merge/vite'] },
246
+ }),
247
+
248
+ async configResolved(resolvedConfig) {
249
+ // Programmatic restarts may reuse this plugin object. Finish the previous run before its mutable generation state is replaced.
250
+ devServer = undefined
251
+ await updates?.dispose()
252
+ await session?.dispose()
253
+ config = resolvedConfig
254
+ integration = createTailwindIntegration(config)
255
+ buildStarts = 0
256
+ const prune = resolvePruneOptions(options.prune, config)
257
+ const pruneActive = config.command === 'build' ? prune.build : prune.dev
258
+ pruneLog = prune.log
259
+ if (prune.libraryDefault && pruneLog) {
260
+ config.logger.info(
261
+ '[@tailwind-merge/vite] Library build: serving the full tailwind-merge config, since the classes a consuming app passes in cannot be scanned here',
262
+ )
263
+ }
264
+
265
+ cssRoot = locateCssRoot()
266
+ session = createGenerationSession({
267
+ cssRoot,
268
+ root: config.root,
269
+ packageName: PACKAGE_NAME,
270
+ importSource: INTERNAL_TAILWIND_MERGE,
271
+ cacheSize: options.cacheSize,
272
+ encoding: options.encoding,
273
+ integration: { ...integration, onDependency: watchDependency },
274
+ prune: pruneActive ? { autoDetectBases: autoDetectBases(config) } : undefined,
275
+ onGenerated: reportPruning,
276
+ onError(error, current) {
277
+ if (config.command === 'build') {
278
+ throw error
279
+ }
280
+ config.logger.error(
281
+ `[@tailwind-merge/vite] Generating the tailwind-merge config failed${current ? ' — keeping the previous one' : ''}: ${error instanceof Error ? error.message : String(error)}`,
282
+ )
283
+ },
284
+ })
285
+ // Observe eager discovery/generation failures before buildStart or a runtime import awaits them, so they cannot become unhandled rejections.
286
+ session.generation.catch((error: unknown) =>
287
+ config.logger.error(error instanceof Error ? error.message : String(error)),
288
+ )
289
+ },
290
+
291
+ async configureServer(server) {
292
+ devServer = server
293
+ updates = createUpdateScheduler(updateAndReload, (error) => {
294
+ config.logger.error(
295
+ `[@tailwind-merge/vite] Updating the tailwind-merge config failed: ${error instanceof Error ? error.message : String(error)}`,
296
+ )
297
+ })
298
+ // Drain dependencies discovered before the server started. Missing imports have recovery directories in that graph; the entrypoint needs its own parent watch when it does not exist yet.
299
+ const cssPath = await cssRoot
300
+ if (cssPath !== null) {
301
+ watchDependency(cssPath, true)
302
+ }
303
+ for (const dependency of session.dependencies) {
304
+ watchDependency(dependency)
305
+ }
306
+ void session.generation.then((generated) => {
307
+ if (generated) {
308
+ watchSourceDirectories(generated)
309
+ }
310
+ })
311
+ },
312
+
313
+ async buildStart() {
314
+ // The first build uses the generation started at configResolved. Later buildStarts are `vite build --watch` rebuilds (or further environments of one build): refresh only when the CSS graph or the used classes changed, so unchanged rebuilds keep the module and stay quiet.
315
+ if (config.command !== 'build') {
316
+ return
317
+ }
318
+ if (buildStarts++ > 0) {
319
+ session.refresh()
320
+ }
321
+ try {
322
+ // Await even the initial generation: failures must fail the build regardless of whether any module imports the runtime subpath.
323
+ await session.generation
324
+ } finally {
325
+ // Rollup uses these watches on failed builds too. Register the retained graph before propagating the error, even when no virtual module has ever loaded.
326
+ for (const dependency of session.dependencies) {
327
+ this.addWatchFile(dependency)
328
+ }
329
+ // Rollup disables glob expansion. Watching source directories observes new templates outside the module graph, with Vite's output/cache exclusions still applied.
330
+ const pruning = session.current?.pruning
331
+ if (pruning) {
332
+ for (const file of pruning.files) {
333
+ this.addWatchFile(file)
334
+ }
335
+ for (const glob of pruning.globs) {
336
+ if (!glob.pattern.startsWith('!')) {
337
+ this.addWatchFile(glob.base)
338
+ }
339
+ }
340
+ }
341
+ }
342
+ },
343
+
344
+ async resolveId(source) {
345
+ // Only the bare specifier is intercepted. If the user aliases the subpath elsewhere, that's their path to use — the plugin doesn't chase it. The generated module's own imports need no interception either: they go through this package's real tailwind-merge re-export, resolvable from anywhere because the plugin package is the user's direct dependency.
346
+ if (source === RUNTIME_SPECIFIER) {
347
+ return (await cssRoot) === null ? null : VIRTUAL_MODULE_ID
348
+ }
349
+ },
350
+
351
+ async load(id) {
352
+ if (id !== VIRTUAL_MODULE_ID) {
353
+ return
354
+ }
355
+ const generated = await session.generation
356
+ if (!generated) {
357
+ return fallbackModuleCode(INTERNAL_TAILWIND_MERGE)
358
+ }
359
+ return generated.code
360
+ },
361
+
362
+ async closeBundle() {
363
+ // Vite can configure the replacement server before closing the old one's environments. An old close must not dispose the new server's queue.
364
+ if (devServer?.environments[this.environment.name] !== this.environment) {
365
+ return
366
+ }
367
+ devServer = undefined
368
+ await updates?.dispose()
369
+ },
370
+
371
+ async hotUpdate({ file }) {
372
+ // Runs once per environment; the work below spans all environments, so let the client run own it.
373
+ if (this.environment.name !== 'client') {
374
+ return
375
+ }
376
+ // Vite hands over the watcher's path with forward slashes; the entrypoint and the reported dependencies keep the platform's separators (on Windows, `require.cache` keys and resolver results do), so the comparison happens in that form.
377
+ const changed = path.normalize(file)
378
+ if (changed === (await cssRoot) || session.dependencies.has(changed)) {
379
+ updates?.schedule('config')
380
+ } else if (session.current?.pruning) {
381
+ // Any other file may be a source: the re-scan itself decides whether the used classes changed (a new file, a deleted one, an edit).
382
+ updates?.schedule('sources')
383
+ }
384
+ },
385
+ }
386
+ }
387
+
388
+ const PACKAGE_NAME = '@tailwind-merge/vite'
389
+
390
+ const RUNTIME_SPECIFIER = '@tailwind-merge/vite/runtime'
391
+
392
+ /** The \0 prefix marks the module as virtual for other plugins. The served code is already plain JavaScript (the configurator emits `format: 'js'`), so no extension is needed to route it through further transforms. */
393
+ const VIRTUAL_MODULE_ID = '\0@tailwind-merge/vite/runtime'
394
+
395
+ /**
396
+ * Where the virtual module imports tailwind-merge's API from: this package's own re-export (src/tailwind-merge.ts). A virtual module has no filesystem location, so a bare 'tailwind-merge' would resolve from the project root and fail under strict package managers — the plugin package itself is the one specifier guaranteed resolvable from anywhere in the user's project, being their direct dependency.
397
+ */
398
+ const INTERNAL_TAILWIND_MERGE = '@tailwind-merge/vite/tailwind-merge'
@@ -0,0 +1,47 @@
1
+ import path from 'node:path'
2
+
3
+ import type { ResolvedConfig } from 'vite'
4
+
5
+ import type { TailwindMergeOptions } from './index'
6
+
7
+ /**
8
+ * Resolves the `prune` option against the build context. `libraryDefault` flags the one implicit decision worth a log line: a library build (`build.lib`) is not pruned unless asked, because the library's `twMerge` calls receive class strings from the consuming app's code, which no scan from inside the library can see.
9
+ */
10
+ export function resolvePruneOptions(
11
+ option: TailwindMergeOptions['prune'],
12
+ config: ResolvedConfig,
13
+ ): { build: boolean; dev: boolean; log: boolean; libraryDefault: boolean } {
14
+ const isLibrary = Boolean(config.build.lib)
15
+ if (option === false) {
16
+ return { build: false, dev: false, log: false, libraryDefault: false }
17
+ }
18
+ if (option === true) {
19
+ return { build: true, dev: false, log: true, libraryDefault: false }
20
+ }
21
+ const build = option?.build ?? !isLibrary
22
+ return {
23
+ build,
24
+ dev: option?.dev ?? false,
25
+ log: option?.log ?? true,
26
+ libraryDefault: isLibrary && option?.build === undefined && config.command === 'build',
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Where Tailwind's automatic source detection starts for this project, mirroring the Tailwind integration in use: `@tailwindcss/vite` scans from the Vite root, while the PostCSS plugin defaults to the working directory. When the Vite plugin isn't in the plugin list, both directories are scanned (nested ones collapse into the outer), which is always a superset of what Tailwind scans — a wider scan only prunes less.
32
+ */
33
+ export function autoDetectBases(config: Pick<ResolvedConfig, 'root' | 'plugins'>): string[] {
34
+ const usesTailwindVitePlugin = config.plugins.some((plugin) =>
35
+ plugin.name.startsWith('@tailwindcss/vite'),
36
+ )
37
+ if (usesTailwindVitePlugin) {
38
+ return [config.root]
39
+ }
40
+ const candidates = [...new Set([config.root, process.cwd()].map((directory) => path.resolve(directory)))]
41
+ return candidates.filter((directory) => !candidates.some((other) => other !== directory && isInside(directory, other)))
42
+ }
43
+
44
+ function isInside(directory: string, ancestor: string): boolean {
45
+ const relative = path.relative(ancestor, directory)
46
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
47
+ }
@@ -0,0 +1,40 @@
1
+ import path from 'node:path'
2
+
3
+ import { type TailwindIntegration } from '@tailwind-merge/configurator'
4
+ import { type ResolvedConfig, type ResolveFn } from 'vite'
5
+
6
+ /** Mirrors Tailwind's Vite resolution before environments exist: aliases first, then normal resolution with CSS-specific extensions and package conditions. Eager generation cannot depend on a CSS transform or a plugin container becoming available. */
7
+ export function createTailwindIntegration(config: ResolvedConfig): TailwindIntegration {
8
+ const css = config.createResolver({
9
+ ...config.resolve,
10
+ extensions: ['.css'],
11
+ mainFields: ['style'],
12
+ conditions: ['style', 'development|production'],
13
+ tryIndex: false,
14
+ preferRelative: true,
15
+ })
16
+ const js = config.createResolver(config.resolve)
17
+ const ssr = Boolean(config.build.ssr)
18
+ return {
19
+ resolveCss: (id, base) => resolve(css, id, base, ssr, true),
20
+ resolveJs: (id, base) => resolve(js, id, base, ssr, false),
21
+ }
22
+ }
23
+
24
+ /** Only filesystem paths can be read by Tailwind's loaders. Keep CSS alias replacements even before their extension is resolved: the configurator finishes resolution and watches the expanded missing targets on failure. Relative replacements remain relative to the importing stylesheet. */
25
+ async function resolve(resolver: ResolveFn, id: string, base: string, ssr: boolean, css: boolean) {
26
+ const importer = path.join(base, '__placeholder__.ts')
27
+ for (const aliasOnly of [true, false]) {
28
+ let file = await resolver(id, importer, aliasOnly, ssr)
29
+ if (!file || (file === id && (aliasOnly || !path.isAbsolute(file)))) {
30
+ continue
31
+ }
32
+ if (file.startsWith('.')) {
33
+ file = path.resolve(base, file)
34
+ }
35
+ if (path.isAbsolute(file) && (file.endsWith('.css') === css || (css && aliasOnly))) {
36
+ return file
37
+ }
38
+ }
39
+ return undefined
40
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The stable import surface of `@tailwind-merge/vite`: `import { twMerge } from '@tailwind-merge/vite/runtime'`.
3
+ *
4
+ * When the plugin is active, Vite never serves this file — imports of the subpath are redirected to an in-memory 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, a test setup that doesn't load the Vite config) and serves tailwind-merge's default behavior, so code using the subpath keeps working outside Vite, just without project-specific precision.
5
+ *
6
+ * 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 generation.ts — the export-parity test in tests/dev-server.test.ts enforces that.
7
+ *
8
+ * `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.
9
+ */
10
+ export {
11
+ createTailwindMerge,
12
+ extendTailwindMerge,
13
+ getDefaultConfig as getConfig,
14
+ mergeConfigs,
15
+ twJoin,
16
+ twMerge,
17
+ validators,
18
+ } from 'tailwind-merge'
19
+ export type { ClassNameValue, ClassValidator, Config, ConfigExtension } from 'tailwind-merge'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Internal re-export of tailwind-merge for the generated runtime module.
3
+ *
4
+ * The generated module is served virtually and has no filesystem location, so a bare `import 'tailwind-merge'` inside it would resolve from the project root — where tailwind-merge doesn't exist under a strict package manager, because the single-install story makes it this package's dependency instead of the user's. Importing through this real file keeps every resolution step ordinary: the user's project resolves `@tailwind-merge/vite` (their own direct dependency), and this file resolves `tailwind-merge` from this package's dependencies.
5
+ *
6
+ * Not part of the public API — import from '@tailwind-merge/vite/runtime' instead, which serves the project-configured twMerge.
7
+ */
8
+ export * from 'tailwind-merge'