diff --git a/src/index.ts b/src/index.ts index 43c1f6f..95123d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,16 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { let ignoredDirectories: string[] = [] let allClassesSet: Set = new Set() let isBuild = false + /** True when this plugin instance is attached to an SSR / server build. */ + let isSSR = false + /** + * Vite Environment API server consumer (`consumer === 'server'` / `name === 'ssr'`). + * Distinct from classic `build.ssr`: in dual client+SSR environments only the + * client instance should write the shared manifest, or a smaller SSR set can + * overwrite classes the client already flushed. Classic `vite build --ssr` + * alone still writes (no Environment API server consumer). + */ + let isEnvironmentServer = false let initialScanComplete = false let projectRoot = process.cwd() let manifestRoot = process.cwd() @@ -122,6 +132,104 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { onWrote: invalidateTailwindCssModules, }) + type EnvironmentLike = { + name?: string + config?: { consumer?: string } + } + + function detectEnvironmentServer( + config?: { build?: { ssr?: boolean | string }, consumer?: string }, + environment?: EnvironmentLike, + ): boolean { + if (environment?.name === 'ssr' || environment?.config?.consumer === 'server') + return true + // Vite Environment API sets `consumer` on the resolved environment config. + if (config?.consumer === 'server') + return true + return false + } + + function syncEnvironmentFlags( + config?: { build?: { ssr?: boolean | string }, consumer?: string }, + environment?: EnvironmentLike, + ): void { + const envServer = detectEnvironmentServer(config, environment) + if (config) + isSSR = Boolean(config.build?.ssr) || envServer + else if (envServer) + isSSR = true + + if (envServer) + isEnvironmentServer = true + } + + /** + * Write the class manifest unless this instance is the Environment API server + * side of a dual client+SSR setup (would shrink a shared manifest). + * Signature matches `writeDirect` so it can be passed to scan helpers. + */ + function writeManifest( + classes: Set = allClassesSet, + dir: string = outputDir, + fileName: string = outputFileName, + root: string = manifestRoot, + ): boolean { + if (isEnvironmentServer) { + if (debug) + console.log('🎩 Skipping manifest write (Vite server environment).') + return false + } + + return writeDirect(classes, dir, fileName, root) + } + + /** + * Persist the in-memory class set to disk. + * Used after transforms complete (`renderStart` / `generateBundle`) so builds + * still get a fresh manifest before emit / first server render. + * Skipped for Environment API server instances — the client instance owns the + * shared manifest file. + */ + function flushManifest(reason: string): void { + if (isEnvironmentServer) { + if (debug) + console.log(`🎩 Skipping manifest flush (${reason}): server environment.`) + return + } + + if (allClassesSet.size === 0) { + if (debug) + console.log(`🎩 Skipping manifest flush (${reason}): no classes.`) + return + } + + if (debug) { + console.log( + `🎩 Flushing manifest (${reason}${isSSR ? ', SSR' : ''}): ${allClassesSet.size} class(es).`, + ) + } + + writeManifest() + } + + /** + * Schedule a manifest write after classes change during transform. + * Builds skip here — `renderStart` / `generateBundle` / `buildEnd` flush + * once after all modules are processed, avoiding per-module sync rewrites. + * Environment API server instances never write (client owns the file). + */ + function scheduleManifestWrite(): void { + if (isBuild || isEnvironmentServer) + return + + writeDebounced( + allClassesSet, + outputDir, + outputFileName, + manifestRoot, + ) + } + const transformCache: Map = new Map() const fileClassMap: Map> = new Map() const classRefCounts: Map = new Map() @@ -185,7 +293,7 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { outputFileName, manifestRoot, debug, - writeDirect, + writeManifest, ) } @@ -199,7 +307,7 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { outputFileName, debug, manifestRoot, - writeDirect, + writeManifest, ) } @@ -222,6 +330,10 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { configResolved(config) { isBuild = config.command === 'build' + // Classic SSR: `build.ssr`. Vite Environment API (Nuxt / Vite 6+): + // `consumer === 'server'` — that path skips manifest writes so a dual + // client+SSR setup cannot shrink the shared file. + syncEnvironmentFlags(config as { build?: { ssr?: boolean | string }, consumer?: string }) projectRoot = config.root manifestRoot = options.manifestRoot ? path.resolve(options.manifestRoot) @@ -234,7 +346,10 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { } if (debug) { - console.log(`🎩 Running in ${isBuild ? 'build' : 'dev'} mode.`) + const mode = isBuild + ? (isEnvironmentServer ? 'server environment build' : isSSR ? 'SSR build' : 'build') + : 'dev' + console.log(`🎩 Running in ${mode} mode.`) } }, @@ -258,19 +373,18 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { outputFileName, debug, manifestRoot, - writeDebounced, + (...args: Parameters) => { + if (isEnvironmentServer) + return + writeDebounced(...args) + }, ) } server.httpServer?.once('listening', () => { if (initialScanComplete && allClassesSet.size > 0) { if (debug) console.log('🎩 Initial write on server ready.') - writeDirect( - allClassesSet, - outputDir, - outputFileName, - manifestRoot, - ) + writeManifest() } }) }, @@ -334,14 +448,14 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { const classesChanged = applyFileClasses(id, fileSpecificClasses) if (classesChanged) { - if (debug) - console.log('🎩 Classes changed, writing output file.') - writeDebounced( - allClassesSet, - outputDir, - outputFileName, - manifestRoot, - ) + if (debug) { + console.log( + isBuild || isEnvironmentServer + ? '🎩 Classes changed (manifest write deferred).' + : '🎩 Classes changed, writing output file.', + ) + } + scheduleManifestWrite() } if (!initialScanComplete) { @@ -356,6 +470,12 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { }, buildStart() { + // Environment API: `this.environment` is available here even when + // `config.build.ssr` was unset in configResolved. + const environment = (this as { environment?: EnvironmentLike }).environment + if (environment) + syncEnvironmentFlags(undefined, environment) + if (debug) console.log('🎩 Build starting, resetting state.') allClassesSet = new Set() classRefCounts.clear() @@ -375,23 +495,34 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { initialScanComplete = true }, - buildEnd() { + /** + * All modules are transformed by the end of the build phase. Flush here so + * builds write a complete manifest before Rollup emits output. + * Environment API server instances skip — client owns the shared file. + */ + renderStart() { if (!isBuild) return - if (allClassesSet.size === 0) { - if (debug) - console.log('🎩 Build ended, no classes found to write.') - return - } + // Vite Environment API: prefer live environment over configResolved snapshot. + const environment = (this as { environment?: EnvironmentLike }).environment + if (environment) + syncEnvironmentFlags(undefined, environment) - if (debug) - console.log('🎩 Build ended, writing final output file.') - writeDirect( - allClassesSet, - outputDir, - outputFileName, - manifestRoot, - ) + flushManifest('renderStart') + }, + + /** + * Final SSR-safe write during bundle generation. Content-deduped with + * `renderStart` / `buildEnd` so duplicate flushes are cheap no-ops. + */ + generateBundle() { + if (!isBuild) return + flushManifest('generateBundle') + }, + + buildEnd() { + if (!isBuild) return + flushManifest('buildEnd') }, } @@ -432,12 +563,7 @@ export default function useClassy(options: ClassyOptions = {}): PluginOption { console.log( '🎩 Manual output generation requested via HTTP endpoint.', ) - writeDirect( - allClassesSet, - outputDir, - outputFileName, - manifestRoot, - ) + writeManifest() res.statusCode = 200 res.end(`Output file generated (${allClassesSet.size} classes)`) }, diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 8936e29..59e0123 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -34,6 +34,9 @@ vi.mock('path', () => ({ })) // Mock utils module +// NOTE: `vi.doMock` is not applied to the static `../index` import (ESM hoisting). +// The plugin closes over the real `createOutputFileWriter`; assert writes via `fs` +// (see scanProjectFiles tests which spy `importActual` for the same reason). vi.doMock('../utils', () => { const writeOutputFileDirect = vi.fn(() => true) const writeOutputFileDebounced = vi.fn() @@ -615,6 +618,264 @@ describe('useClassy plugin', () => { expect(plugin.buildEnd).toBeDefined() }) + it('should expose renderStart and generateBundle for SSR-safe flushes', () => { + const plugin = useClassy({ debug: true }) as Plugin + + expect(typeof plugin.renderStart).toBe('function') + expect(typeof plugin.generateBundle).toBe('function') + }) + + it('should flush the manifest on renderStart during classic SSR builds', async () => { + // Classic `build.ssr` (no Environment API server consumer) still owns the + // write — Plugin uses real createOutputFileWriter (doMock does not replace + // the static index import); observe via mocked fs. + const fs = await import('fs') + const writeSpy = vi.spyOn(fs.default, 'writeFileSync').mockImplementation(() => undefined) + const renameSpy = vi.spyOn(fs.default, 'renameSync').mockImplementation(() => undefined) + ;(fs.default.existsSync as unknown as ReturnType).mockReturnValue(false) + ;(fs.default.mkdirSync as unknown as ReturnType).mockImplementation(() => undefined) + + const plugin = useClassy({ + debug: true, + manifestRoot: '/project', + }) as Plugin + + if (plugin.configResolved) { + await plugin.configResolved({ + command: 'build', + root: '/project/app', + build: { ssr: true }, + } as never) + } + + const transform = plugin.transform as ( + this: { addWatchFile: (id: string) => void }, + code: string, + id: string, + ) => { code: string } | null + + transform.call( + { addWatchFile: vi.fn() }, + '
x
', + '/project/app/Component.vue', + ) + + writeSpy.mockClear() + renameSpy.mockClear() + + if (typeof plugin.renderStart === 'function') { + // No Environment API server consumer — classic SSR build still flushes. + await plugin.renderStart.call({} as never) + } + + expect(writeSpy).toHaveBeenCalled() + expect( + writeSpy.mock.calls.some( + ([filePath, content]) => + String(filePath).includes('output.classy.html') + && String(content).includes('hover:text-red-500'), + ), + ).toBe(true) + + writeSpy.mockRestore() + renameSpy.mockRestore() + }) + + it('should not flush the manifest from a Vite server environment', async () => { + // Dual client+SSR: server environment must not overwrite the shared file. + const fs = await import('fs') + const writeSpy = vi.spyOn(fs.default, 'writeFileSync').mockImplementation(() => undefined) + ;(fs.default.existsSync as unknown as ReturnType).mockReturnValue(false) + ;(fs.default.mkdirSync as unknown as ReturnType).mockImplementation(() => undefined) + vi.spyOn(fs.default, 'renameSync').mockImplementation(() => undefined) + + const plugin = useClassy({ + debug: true, + manifestRoot: '/project', + }) as Plugin + + if (plugin.configResolved) { + await plugin.configResolved({ + command: 'build', + root: '/project', + build: { ssr: false }, + consumer: 'server', + } as never) + } + + const transform = plugin.transform as ( + this: { addWatchFile: (id: string) => void }, + code: string, + id: string, + ) => { code: string } | null + + transform.call( + { addWatchFile: vi.fn() }, + '
x
', + '/project/Component.vue', + ) + + writeSpy.mockClear() + + if (typeof plugin.renderStart === 'function') { + await plugin.renderStart.call({ + environment: { name: 'ssr', config: { consumer: 'server' } }, + } as never) + } + + if (typeof plugin.generateBundle === 'function') { + const generateBundle = plugin.generateBundle as ( + this: unknown, + outputOptions: object, + bundle: object, + isWrite?: boolean, + ) => void | Promise + await generateBundle.call({}, {}, {}, false) + } + + if (typeof plugin.buildEnd === 'function') { + await plugin.buildEnd.call({} as never) + } + + expect( + writeSpy.mock.calls.some(([filePath]) => + String(filePath).includes('output.classy.html'), + ), + ).toBe(false) + + writeSpy.mockRestore() + }) + + it('should flush the manifest on generateBundle during builds', async () => { + const fs = await import('fs') + const writeSpy = vi.spyOn(fs.default, 'writeFileSync').mockImplementation(() => undefined) + ;(fs.default.existsSync as unknown as ReturnType).mockReturnValue(false) + ;(fs.default.mkdirSync as unknown as ReturnType).mockImplementation(() => undefined) + vi.spyOn(fs.default, 'renameSync').mockImplementation(() => undefined) + + const plugin = useClassy({ + debug: true, + manifestRoot: '/project', + }) as Plugin + + if (plugin.configResolved) { + await plugin.configResolved({ + command: 'build', + root: '/project', + build: { ssr: false }, + } as never) + } + + const transform = plugin.transform as ( + this: { addWatchFile: (id: string) => void }, + code: string, + id: string, + ) => { code: string } | null + + transform.call( + { addWatchFile: vi.fn() }, + '
x
', + '/project/Component.vue', + ) + + writeSpy.mockClear() + + // Rollup/Vite signature: generateBundle(outputOptions, bundle, isWrite) + if (typeof plugin.generateBundle === 'function') { + const generateBundle = plugin.generateBundle as ( + this: unknown, + outputOptions: object, + bundle: object, + isWrite?: boolean, + ) => void | Promise + + await generateBundle.call({}, {}, {}, false) + } + + expect( + writeSpy.mock.calls.some( + ([filePath, content]) => + String(filePath).includes('output.classy.html') + && String(content).includes('focus:ring-2'), + ), + ).toBe(true) + + writeSpy.mockRestore() + }) + + it('should not flush the manifest on renderStart in dev mode', async () => { + const fs = await import('fs') + const writeSpy = vi.spyOn(fs.default, 'writeFileSync').mockImplementation(() => undefined) + + const plugin = useClassy({ debug: true }) as Plugin + + if (plugin.configResolved) { + await plugin.configResolved({ + command: 'serve', + root: '/mock/cwd', + build: {}, + } as never) + } + + writeSpy.mockClear() + + if (typeof plugin.renderStart === 'function') { + await plugin.renderStart.call({} as never) + } + + expect( + writeSpy.mock.calls.some(([filePath]) => + String(filePath).includes('output.classy.html'), + ), + ).toBe(false) + + writeSpy.mockRestore() + }) + + it('should not write the manifest from transform during builds', async () => { + const fs = await import('fs') + const writeSpy = vi.spyOn(fs.default, 'writeFileSync').mockImplementation(() => undefined) + ;(fs.default.existsSync as unknown as ReturnType).mockReturnValue(false) + ;(fs.default.mkdirSync as unknown as ReturnType).mockImplementation(() => undefined) + vi.spyOn(fs.default, 'renameSync').mockImplementation(() => undefined) + + const plugin = useClassy({ + debug: true, + manifestRoot: '/project', + }) as Plugin + + if (plugin.configResolved) { + await plugin.configResolved({ + command: 'build', + root: '/project', + build: { ssr: true }, + } as never) + } + + writeSpy.mockClear() + + const transform = plugin.transform as ( + this: { addWatchFile: (id: string) => void }, + code: string, + id: string, + ) => { code: string } | null + + transform.call( + { addWatchFile: vi.fn() }, + '
x
', + '/project/Component.vue', + ) + + // Builds defer writes to renderStart / generateBundle / buildEnd. + expect( + writeSpy.mock.calls.some(([filePath]) => + String(filePath).includes('output.classy.html'), + ), + ).toBe(false) + + writeSpy.mockRestore() + }) + it('should handle configResolved hook', () => { const plugin = useClassy({ debug: true }) as Plugin