diff --git a/packages/onchainkit/package.json b/packages/onchainkit/package.json index 26f8e02552..e4518348ba 100644 --- a/packages/onchainkit/package.json +++ b/packages/onchainkit/package.json @@ -16,7 +16,7 @@ "format": "prettier --write .", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:coverage": "vitest run --coverage", + "test:coverage": "vitest run --coverage --coverage.all=false", "test:watch": "vitest", "test:ui": "vitest --ui", "get-next-version": "node ./scripts/get-next-version.js", @@ -114,6 +114,7 @@ "exports": { "./package.json": "./package.json", "./styles.css": "./dist/assets/style.css", + "./onchainkit.css": "./dist/assets/onchainkit.css", "./theme": "./dist/styles/theme.js", ".": { "types": "./dist/index.d.ts", diff --git a/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts b/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts index fc0099be84..db7289e209 100644 --- a/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts +++ b/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts @@ -5,7 +5,11 @@ import { babelPrefixReactClassNames } from '../babel-prefix-react-classnames'; // Helper function to transform code using the plugin function transform( code: string, - options: { prefix: string; cnUtil?: string | false } = { prefix: 'prefix-' }, + options: { + prefix: string; + cnUtil?: string | false; + universalClass?: string; + } = { prefix: 'prefix-' }, ): string { const result = transformSync(code, { plugins: [ @@ -218,4 +222,146 @@ describe('babel-prefix-react-classnames', () => { 'isActive ? `prefix-active-${dynamic}` : "prefix-inactive-class"', ); }); + + describe('universalClass option', () => { + it('should add universal class to HTML elements', () => { + const code = '
Hello
'; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('className: "prefix-el"'); + }); + + it('should add universal class to HTML elements with existing className', () => { + const code = ''; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('className: "prefix-foo prefix-el"'); + }); + + it('should NOT add universal class to React components', () => { + const code = 'Hello'; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).not.toContain('className'); + }); + + it('should NOT add universal class to React components with existing className', () => { + const code = ''; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('className: "prefix-foo"'); + expect(result).not.toContain('"prefix-foo el"'); + }); + + it('should add universal class to multiple HTML elements', () => { + const code = ` +
+ + Text +
+ `; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + // Each HTML element should get the universal class + expect(result.match(/className: "prefix-el"/g)?.length).toBe(3); + }); + + it('should add universal class to HTML elements but not React components in mixed JSX', () => { + const code = ` +
+ + +
+ `; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + // Only div and button should get the universal class (2 times), not MyComponent + expect(result.match(/className: "prefix-el"/g)?.length).toBe(2); + }); + + it('should add universal class as first argument in cn() calls for HTML elements', () => { + const code = ''; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('cn("prefix-foo"'); + expect(result).toContain('"prefix-bar"'); + expect(result).toContain('"prefix-el"'); + }); + + it('should NOT add universal class to JSX member expressions', () => { + const code = 'Content'; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).not.toContain('className'); + }); + + it('should add universal class to template literal className on HTML elements', () => { + const code = '
Hello
'; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('prefix-el'); + expect(result).toContain('prefix-foo'); + }); + + it('should add universal class to HTML elements without className', () => { + const code = '
Hello
'; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + expect(result).toContain('className: "prefix-el"'); + }); + + it('should not add universal class if no universalClass option is set', () => { + const code = '
Hello
'; + const result = transform(code, { + prefix: 'prefix-', + }); + expect(result).not.toContain('className'); + }); + + it('should handle React components without adding universal class', () => { + const code = ''; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + // React component should get prefix but not universal class + expect(result).toContain('prefix-foo'); + expect(result).not.toContain('prefix-el'); + }); + + it('should handle multiple HTML elements with and without className', () => { + const code = ` +
+ Hello + +
+ `; + const result = transform(code, { + prefix: 'prefix-', + universalClass: 'el', + }); + // All HTML elements should get universal class + expect(result.match(/prefix-el/g)?.length).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts b/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts new file mode 100644 index 0000000000..d89d889428 --- /dev/null +++ b/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts @@ -0,0 +1,539 @@ +import { describe, it, expect } from 'vitest'; +import postcss from 'postcss'; +import postcssCreateScopedStyles from '../postcss-create-scoped-styles.js'; + +describe('postcssCreateScopedStyles', () => { + const runPlugin = (input: string, options = {}) => { + return postcss([postcssCreateScopedStyles(options)]) + .process(input, { from: undefined }) + .then((result) => result.css); + }; + + it('should split :root variables by --ock- prefix', async () => { + const input = ` + :root { + --ock-primary: blue; + --font-mono: monospace; + --color-red: red; + } + `; + + const output = await runPlugin(input); + + expect(output).toContain('--ock-primary: blue'); + expect(output).toContain(':root'); + expect(output).toContain('--ock-font-mono: monospace'); + expect(output).toContain('--ock-color-red: red'); + }); + + it('should transform global element selectors', async () => { + const input = ` + * { + box-sizing: border-box; + } + html { + font-family: Inter; + } + `; + + const output = await runPlugin(input); + + expect(output).toContain('.ock\\:el {'); + expect(output).toContain('box-sizing: border-box'); + expect(output).toContain('font-family: Inter'); + }); + + it('should not transform existing .ock: prefixed classes', async () => { + const input = ` + .ock\\:bg-primary { + background: blue; + } + `; + + const output = await runPlugin(input); + + expect(output).toContain('.ock\\:bg-primary'); + expect(output).not.toContain('.ock\\:el .ock\\:bg-primary'); + }); + + it('should transform pseudo-elements correctly', async () => { + const input = ` + ::before { + content: ''; + } + `; + + const output = await runPlugin(input); + + expect(output).toContain(':where(.ock\\:el)::before'); + }); + + it('should move @import rules to top', async () => { + const input = ` + .foo { color: red; } + @import "test.css"; + .bar { color: blue; } + `; + + const output = await runPlugin(input); + const lines = output.trim().split('\n'); + expect(lines[0]).toContain('@import'); + }); + + it('should consolidate layers when enabled', async () => { + const input = ` + @layer theme { + .theme { color: blue; } + } + @layer base { + .base { margin: 0; } + } + `; + + const output = await runPlugin(input, { consolidateLayers: true }); + expect(output).not.toContain('@layer onchainkit'); + expect(output).not.toContain('@layer theme'); + expect(output).not.toContain('@layer base'); + expect(output).toContain('Theme section'); + expect(output).toContain('Base section'); + }); + + it('should transform @keyframes names', async () => { + const input = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('@keyframes ock-fadeIn'); + }); + + it('should transform animation references', async () => { + const input = ` + @keyframes fadeIn { + from { opacity: 0; } + } + .element { + animation: fadeIn 1s; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('animation: ock-fadeIn 1s'); + }); + + it('should transform @property rules', async () => { + const input = ` + @property --custom-color { + syntax: ""; + inherits: false; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('@property --ock-custom-color'); + }); + + it('should handle @supports rules', async () => { + const input = ` + @supports (display: grid) { + .grid { + display: grid; + --spacing: 10px; + } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('@supports'); + expect(output).toContain('--ock-spacing'); + }); + + it('should skip keyframes content transformation', async () => { + const input = ` + @keyframes slide { + from { transform: translateX(0); } + to { transform: translateX(100px); } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('from'); + expect(output).toContain('to'); + }); + + it('should transform variable references in values', async () => { + const input = ` + .element { + color: var(--primary); + } + `; + + const output = await runPlugin(input); + expect(output).toContain('var(--ock-primary)'); + }); + + it('should transform variable references with fallbacks', async () => { + const input = ` + .element { + color: var(--primary, blue); + } + `; + + const output = await runPlugin(input); + expect(output).toContain('var(--ock-primary, blue)'); + }); + + it('should transform nested variable references', async () => { + const input = ` + .element { + color: var(--primary, var(--fallback)); + } + `; + + const output = await runPlugin(input); + expect(output).toContain('var(--ock-primary, var(--ock-fallback))'); + }); + + it('should handle :host selector', async () => { + const input = ` + :host { + display: block; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el:host {'); + }); + + it('should handle element selectors with pseudo-classes', async () => { + const input = ` + input:focus { + outline: 2px solid blue; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('input:where(.ock\\:el):focus'); + }); + + it('should handle ::after pseudo-element', async () => { + const input = ` + ::after { + content: ''; + } + `; + + const output = await runPlugin(input); + expect(output).toContain(':where(.ock\\:el)::after'); + }); + + it('should handle ::backdrop pseudo-element', async () => { + const input = ` + ::backdrop { + background: rgba(0,0,0,0.5); + } + `; + + const output = await runPlugin(input); + expect(output).toContain(':where(.ock\\:el)::backdrop'); + }); + + it('should handle functional pseudo-classes', async () => { + const input = ` + :where(.class) { + color: red; + } + :is(.class) { + color: blue; + } + :not(.class) { + color: green; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el:where(.class)'); + expect(output).toContain('.ock\\:el:is(.class)'); + expect(output).toContain('.ock\\:el:not(.class)'); + }); + + it('should skip selectors with & parent reference', async () => { + const input = ` + .parent { + &:hover { + color: red; + } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('&:hover'); + }); + + it('should skip theme selectors', async () => { + const input = ` + [data-ock-theme="dark"] { + --bg: black; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('[data-ock-theme="dark"]'); + expect(output).not.toContain('.ock\\:el [data-ock-theme'); + }); + + it('should handle animation-name property', async () => { + const input = ` + @keyframes spin { + to { transform: rotate(360deg); } + } + .spinner { + animation-name: spin; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('animation-name: ock-spin'); + }); + + it('should handle element selectors with attribute selectors', async () => { + const input = ` + input[type="text"] { + border: 1px solid gray; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('input:where(.ock\\:el)[type="text"]'); + }); + + it('should handle multiple selectors', async () => { + const input = ` + h1, h2, h3 { + margin: 0; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('h1:where(.ock\\:el)'); + expect(output).toContain('h2:where(.ock\\:el)'); + expect(output).toContain('h3:where(.ock\\:el)'); + }); + + it('should handle :root, :host combined selector', async () => { + const input = ` + :root, :host { + --color: red; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('--ock-color: red'); + }); + + it('should handle custom scope class option', async () => { + const input = ` + html { + font-size: 16px; + } + `; + + const output = await runPlugin(input, { scopeClass: '.custom' }); + expect(output).toContain('.custom {'); + }); + + it('should handle pseudo-selectors like :focus', async () => { + const input = ` + :focus { + outline: 2px solid blue; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el:focus'); + }); + + it('should handle complex element selectors', async () => { + const input = ` + body { + margin: 0; + } + hr { + border: 0; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('body:where(.ock\\:el)'); + expect(output).toContain('hr:where(.ock\\:el)'); + }); + + it('should handle layer declarations without content', async () => { + const input = ` + @layer theme, base, utilities; + @layer theme { + .theme { color: blue; } + } + `; + + const output = await runPlugin(input, { consolidateLayers: true }); + expect(output).not.toContain('@layer onchainkit'); + expect(output).not.toContain('@layer theme, base, utilities'); + expect(output).toContain('Theme section'); + }); + + it('should handle multiple imports', async () => { + const input = ` + @import "first.css"; + @import "second.css"; + .element { color: red; } + `; + + const output = await runPlugin(input); + expect(output).toContain('@import "first.css"'); + expect(output).toContain('@import "second.css"'); + // Imports should be at the top + expect(output.indexOf('@import "first.css"')).toBeLessThan( + output.indexOf('.element'), + ); + }); + + it('should handle ::file-selector-button pseudo-element', async () => { + const input = ` + ::file-selector-button { + padding: 10px; + } + `; + + const output = await runPlugin(input); + expect(output).toContain(':where(.ock\\:el)::file-selector-button'); + }); + + it('should handle :has() functional pseudo-class', async () => { + const input = ` + :has(.child) { + color: red; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el:has(.child)'); + }); + + it('should handle variable declarations in keyframes with animation reference', async () => { + const input = ` + @keyframes fadeIn { + to { + opacity: 1; + --animate-delay: fadeIn; + } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('--animate-delay: ock-fadeIn'); + }); + + it('should handle selectors with parentheses in :not()', async () => { + const input = ` + :not(.foo, .bar) { + color: red; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el:not(.foo, .bar)'); + }); + + it('should transform variables in @supports params', async () => { + const input = ` + @supports (--custom: value) { + .element { + color: var(--custom); + } + } + `; + + const output = await runPlugin(input); + expect(output).toContain('var(--ock-custom)'); + }); + + it('should transform animation variables with empty fallback', async () => { + const input = ` + .element { + color: var(--primary,); + } + `; + + const output = await runPlugin(input); + expect(output).toContain('var(--ock-primary,)'); + }); + + it('should handle complex selectors with class combinations', async () => { + const input = ` + div.container { + margin: 0; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el div.container'); + }); + + it('should handle other non-element selectors', async () => { + const input = ` + .custom-class { + color: red; + } + `; + + const output = await runPlugin(input); + expect(output).toContain('.ock\\:el .custom-class'); + }); + + it('should handle single import with proper formatting', async () => { + const input = ` + @import "single.css"; + .element { color: red; } + `; + + const output = await runPlugin(input); + expect(output).toContain('@import "single.css"'); + }); + + it('should handle layer with no matching content', async () => { + const input = ` + @layer properties { + .prop { color: blue; } + } + `; + + const output = await runPlugin(input, { consolidateLayers: true }); + expect(output).not.toContain('@layer onchainkit'); + expect(output).not.toContain('@layer properties'); + expect(output).toContain('Properties section'); + }); + + it('should insert layers after imports when consolidating', async () => { + const input = ` + @import "first.css"; + @layer theme { + .theme { color: blue; } + } + @layer base { + .base { margin: 0; } + } + `; + + const output = await runPlugin(input, { consolidateLayers: true }); + const lines = output.trim().split('\n'); + expect(lines[0]).toContain('@import'); + expect(output).toContain('Theme section'); + expect(output).toContain('Base section'); + expect(output).not.toContain('@layer theme'); + expect(output).not.toContain('@layer base'); + }); +}); diff --git a/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts b/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts new file mode 100644 index 0000000000..2ef6d58225 --- /dev/null +++ b/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts @@ -0,0 +1,337 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'node:path'; +import { dualCSSPlugin } from '../vite-dual-css.js'; + +// Define types locally since we don't have rollup installed +interface OutputAsset { + type: 'asset'; + fileName: string; + source: string | Uint8Array; + name?: string; +} + +interface OutputChunk { + type: 'chunk'; + fileName: string; + [key: string]: unknown; +} + +type OutputBundle = Record; + +interface NormalizedOutputOptions { + dir?: string; + file?: string; + [key: string]: unknown; +} + +// Mock fs module +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +describe('dualCSSPlugin', () => { + const mockFs = fs as unknown as { + existsSync: ReturnType; + mkdirSync: ReturnType; + writeFileSync: ReturnType; + }; + + // Helper to call writeBundle (handles both function and object forms) + async function callWriteBundle( + plugin: ReturnType, + outputOptions: NormalizedOutputOptions, + bundle: OutputBundle, + ) { + const writeBundle = plugin.writeBundle; + if (typeof writeBundle === 'function') { + await writeBundle.call({} as any, outputOptions as any, bundle as any); + } else if ( + writeBundle && + typeof writeBundle === 'object' && + 'handler' in writeBundle + ) { + await writeBundle.handler.call( + {} as any, + outputOptions as any, + bundle as any, + ); + } + } + + beforeEach(() => { + vi.clearAllMocks(); + // Reset console methods + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a plugin with correct name', () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + expect(plugin.name).toBe('dual-css'); + }); + + it('should process CSS and write scoped file when style.css is found', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'onchainkit.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '.test { color: red; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(true); + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(console.log).toHaveBeenCalledWith('📦 Generating scoped styles...'); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + path.join('/output', 'assets', 'onchainkit.css'), + expect.any(String), + ); + expect(console.log).toHaveBeenCalledWith( + '✅ Generated scoped styles: assets/onchainkit.css', + ); + }); + + it('should create assets directory if it does not exist', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '.test { color: blue; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(false); + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(mockFs.mkdirSync).toHaveBeenCalledWith( + path.join('/output', 'assets'), + { recursive: true }, + ); + }); + + it('should warn when no style.css is found in bundle', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const bundle: OutputBundle = { + 'other-file.js': { + type: 'chunk', + fileName: 'other-file.js', + } as any, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(console.warn).toHaveBeenCalledWith( + '⚠️ No style.css found in bundle', + ); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should handle outputOptions with file instead of dir', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '.test { color: green; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + file: '/output/bundle.js', + } as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(true); + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + path.join('/output', 'assets', 'scoped.css'), + expect.any(String), + ); + }); + + it('should handle errors during CSS processing', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '.test { color: red; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + const testError = new Error('PostCSS processing failed'); + mockFs.existsSync.mockReturnValue(true); + mockFs.writeFileSync.mockImplementation(() => { + throw testError; + }); + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(console.error).toHaveBeenCalledWith( + '❌ Error generating scoped styles:', + testError, + ); + }); + + it('should apply postcss transformations to CSS', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: 'html { --color: red; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(true); + + await callWriteBundle(plugin, outputOptions, bundle); + + // Verify that writeFileSync was called with transformed CSS + expect(mockFs.writeFileSync).toHaveBeenCalled(); + const writtenCss = mockFs.writeFileSync.mock.calls[0][1] as string; + + // The CSS should be transformed by postcssCreateScopedStyles + // html should be transformed to .ock:el and --color to --ock-color + expect(writtenCss).toContain('.ock\\:el'); + expect(writtenCss).toContain('--ock-color'); + }); + + it('should handle empty outputOptions gracefully', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '.test { color: red; }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = + {} as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(true); + + await callWriteBundle(plugin, outputOptions, bundle); + + // Should use empty string as dir when no dir or file is provided + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + path.join('assets', 'scoped.css'), + expect.any(String), + ); + }); + + it('should process CSS with consolidateLayers option enabled', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const cssAsset: OutputAsset = { + type: 'asset', + fileName: 'assets/style.css', + source: '@layer theme { .theme { color: blue; } }', + name: 'style.css', + }; + + const bundle: OutputBundle = { + 'assets/style.css': cssAsset, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + mockFs.existsSync.mockReturnValue(true); + + await callWriteBundle(plugin, outputOptions, bundle); + + const writtenCss = mockFs.writeFileSync.mock.calls[0][1] as string; + + // Should consolidate layers without wrapping in @layer onchainkit + expect(writtenCss).not.toContain('@layer onchainkit'); + expect(writtenCss).not.toContain('@layer theme'); + expect(writtenCss).toContain('Theme section'); + }); + + it('should skip processing if asset is not of type asset', async () => { + const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' }); + + const bundle: OutputBundle = { + 'assets/style.css': { + type: 'chunk', + fileName: 'assets/style.css', + } as any, + }; + + const outputOptions: NormalizedOutputOptions = { + dir: '/output', + } as NormalizedOutputOptions; + + await callWriteBundle(plugin, outputOptions, bundle); + + expect(console.warn).toHaveBeenCalledWith( + '⚠️ No style.css found in bundle', + ); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/onchainkit/plugins/babel-prefix-react-classnames.ts b/packages/onchainkit/plugins/babel-prefix-react-classnames.ts index 2cac07412f..7ec9b65840 100644 --- a/packages/onchainkit/plugins/babel-prefix-react-classnames.ts +++ b/packages/onchainkit/plugins/babel-prefix-react-classnames.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { declare } from '@babel/helper-plugin-utils'; import * as t from '@babel/types'; import { prefixStringParts } from '../src/internal/utils/prefixStringParts'; @@ -48,21 +49,64 @@ function processTemplateLiteral( export function babelPrefixReactClassNames({ prefix, cnUtil = 'cn', + universalClass, }: { prefix: string; cnUtil?: string | false; + universalClass?: string; }): ReturnType { return declare(({ types }) => { return { visitor: { + JSXOpeningElement(path) { + // Only process if universalClass is defined + if (!universalClass) return; + + // Only add to HTML elements (lowercase tag names) + if (!types.isJSXIdentifier(path.node.name)) return; + if (!/^[a-z]/.test(path.node.name.name)) return; + + // Check if element already has a className attribute + const hasClassName = path.node.attributes.some( + (attr) => + types.isJSXAttribute(attr) && + types.isJSXIdentifier(attr.name) && + attr.name.name === 'className', + ); + + // If no className, add one with just the universal class + if (!hasClassName) { + const prefixedUniversalClass = `${prefix}${universalClass}`; + path.node.attributes.push( + types.jsxAttribute( + types.jsxIdentifier('className'), + types.stringLiteral(prefixedUniversalClass), + ), + ); + } + }, JSXAttribute(path) { if (path.node.name.name !== 'className') return; const value = path.node.value; + // Check if this className is on an HTML element (lowercase tag) + const parent = path.parent; + const isHTMLElement = + types.isJSXOpeningElement(parent) && + types.isJSXIdentifier(parent.name) && + /^[a-z]/.test(parent.name.name); + // Handle string literals if (types.isStringLiteral(value)) { value.value = prefixStringParts(value.value, prefix); + // Add universal class only to HTML elements + if (universalClass && isHTMLElement) { + const prefixedUniversalClass = `${prefix}${universalClass}`; + if (!value.value.includes(prefixedUniversalClass)) { + value.value = `${value.value} ${prefixedUniversalClass}`; + } + } } if (types.isJSXExpressionContainer(value)) { @@ -71,6 +115,20 @@ export function babelPrefixReactClassNames({ // Handle template literals if (types.isTemplateLiteral(expression)) { processTemplateLiteral(expression, prefix); + // Add universal class only to HTML elements + if (universalClass && isHTMLElement) { + const prefixedUniversalClass = `${prefix}${universalClass}`; + // Add as last quasi + const lastQuasi = + expression.quasis[expression.quasis.length - 1]; + if ( + lastQuasi && + !lastQuasi.value.raw.includes(prefixedUniversalClass) + ) { + lastQuasi.value.raw = `${lastQuasi.value.raw} ${prefixedUniversalClass}`; + lastQuasi.value.cooked = lastQuasi.value.raw; + } + } } // Handle cnUtil function calls @@ -131,8 +189,23 @@ export function babelPrefixReactClassNames({ // Leave identifiers and member expressions untouched return arg; }); + + // Add universal class only to HTML elements + if (universalClass && isHTMLElement) { + const prefixedUniversalClass = `${prefix}${universalClass}`; + expression.arguments.push( + types.stringLiteral(prefixedUniversalClass), + ); + } } } + + // Handle elements without className - add it if it's an HTML element + /* c8 ignore next 4 */ + if (!value && universalClass && isHTMLElement) { + const prefixedUniversalClass = `${prefix}${universalClass}`; + path.node.value = types.stringLiteral(prefixedUniversalClass); + } }, }, }; diff --git a/packages/onchainkit/plugins/postcss-create-scoped-styles.ts b/packages/onchainkit/plugins/postcss-create-scoped-styles.ts new file mode 100644 index 0000000000..83153b99d6 --- /dev/null +++ b/packages/onchainkit/plugins/postcss-create-scoped-styles.ts @@ -0,0 +1,451 @@ +/* eslint-disable complexity */ +import postcss, { type PluginCreator, type Rule, type AtRule } from 'postcss'; + +interface PostCSSScopeToClassOptions { + scopeClass?: string; + consolidateLayers?: boolean; +} + +const postcssCreateScopedStyles: PluginCreator = ( + options: PostCSSScopeToClassOptions = {}, +) => { + const { scopeClass = '.ock\\:el', consolidateLayers = false } = options; + + return { + postcssPlugin: 'postcss-create-scoped-styles', + prepare() { + return { + Root(root) { + // First pass: collect and consolidate layers if enabled + if (consolidateLayers) { + consolidateAllLayers(root); + } + + // Move @import rules to the top of the file + moveImportsToTop(root); + + // Collect all keyframe names before transformation + const keyframeNames = new Set(); + root.walkAtRules('keyframes', (atRule) => { + keyframeNames.add(atRule.params); + }); + + // Second pass: transform selectors and variable references + root.walkRules((rule) => { + // Transform :root rules specially + if (rule.selector === ':root' || rule.selector === ':root, :host') { + transformRootRule(rule, keyframeNames); + } else if (isInsideAtRule(rule, ['keyframes', 'document'])) { + // Skip selector transformation for keyframes and document rules, + // but still transform variable references + rule.walkDecls((decl) => + transformVariableReferences(decl, keyframeNames), + ); + } else if (isInsideAtRule(rule, ['supports'])) { + // For @supports rules, transform variables but handle selectors carefully + transformRuleInSupports(rule, scopeClass, keyframeNames); + } else { + // Transform other global selectors + transformGlobalSelector(rule, scopeClass); + + // Transform variable declarations and references in all rules + rule.walkDecls((decl) => { + // Transform variable declarations to have --ock- prefix + if ( + decl.prop.startsWith('--') && + !decl.prop.startsWith('--ock-') + ) { + decl.prop = `--ock-${decl.prop.slice(2)}`; + } + // Transform variable references + transformVariableReferences(decl, keyframeNames); + }); + } + }); + + // Third pass: transform variable references in at-rules (like @supports) + root.walkAtRules((atRule) => { + // Transform @property rule names to use --ock- prefix + if ( + atRule.name === 'property' && + atRule.params.startsWith('--') && + !atRule.params.startsWith('--ock-') + ) { + atRule.params = `--ock-${atRule.params.slice(2)}`; + } + + // Transform @keyframes names to use ock- prefix + if ( + atRule.name === 'keyframes' && + !atRule.params.startsWith('ock-') + ) { + atRule.params = `ock-${atRule.params}`; + } + + atRule.walkDecls((decl) => + transformVariableReferences(decl, keyframeNames), + ); + }); + }, + }; + }, + }; +}; + +function moveImportsToTop(root: postcss.Root) { + const imports: postcss.AtRule[] = []; + + // Collect all @import rules + root.walkAtRules('import', (atRule) => { + imports.push(atRule.clone()); + atRule.remove(); + }); + + // Add imports at the beginning of the file + if (imports.length > 0) { + // Process imports in reverse order since we're prepending + for (let i = imports.length - 1; i >= 0; i--) { + root.prepend(imports[i]); + } + } +} + +function consolidateAllLayers(root: postcss.Root) { + const layerOrder = ['properties', 'theme', 'base', 'utilities']; + const layerContents: Record = {}; + const layerDeclarations: AtRule[] = []; + + // First pass: collect all layer contents and remove layer at-rules + root.walk((node) => { + if (node.type === 'atrule' && node.name === 'layer') { + if (node.params && !node.params.includes(',')) { + // Single layer with content + const layerName = node.params.trim(); + if (layerOrder.includes(layerName)) { + if (!layerContents[layerName]) { + layerContents[layerName] = []; + } + // Move all children to our collection + node.each((child) => { + layerContents[layerName].push(child.clone()); + }); + node.remove(); + } + } else if (node.params && !node.nodes) { + // Layer declaration without content (e.g., @layer theme, base, utilities;) + layerDeclarations.push(node); + node.remove(); + } + } + }); + + // Insert layer contents directly into root without wrapper + if (Object.keys(layerContents).length > 0) { + // Find where to insert content (after imports) + let insertAfterNode: postcss.ChildNode | null = null; + root.each((node) => { + if (node.type === 'atrule' && node.name === 'import') { + insertAfterNode = node; + } + }); + + // Build all nodes to insert + const nodesToInsert: postcss.ChildNode[] = []; + + layerOrder.forEach((layerName) => { + if (layerContents[layerName]) { + // Add section comment + const comment = postcss.comment({ + text: ` ${layerName.charAt(0).toUpperCase() + layerName.slice(1)} section `, + }); + + nodesToInsert.push(comment); + + // Add all rules from this layer + layerContents[layerName].forEach((node) => { + nodesToInsert.push(node); + }); + } + }); + + // Insert all nodes after imports (or at beginning if no imports) + if (insertAfterNode) { + nodesToInsert.reverse().forEach((node) => { + insertAfterNode!.after(node); + }); + } else { + nodesToInsert.reverse().forEach((node) => { + root.prepend(node); + }); + } + + // Format the inserted content by cleaning up the root + root.cleanRaws(); + } +} + +function isInsideAtRule(rule: Rule, atRuleNames: string[]): boolean { + let parent = rule.parent; + while (parent && parent.type !== 'root') { + if ( + parent.type === 'atrule' && + atRuleNames.includes((parent as AtRule).name) + ) { + return true; + } + parent = parent.parent as typeof rule.parent | undefined; + } + return false; +} + +function transformRootRule(rule: Rule, keyframeNames: Set) { + // Transform all variables to have --ock- prefix and keep them on :root + rule.walkDecls((decl) => { + // If variable doesn't already have --ock- prefix, add it + if (decl.prop.startsWith('--') && !decl.prop.startsWith('--ock-')) { + decl.prop = `--ock-${decl.prop.slice(2)}`; // Remove existing -- and add --ock- + } + + // Also transform variable references in the value + transformVariableReferences(decl, keyframeNames); + }); +} + +function transformVariableReferences( + decl: postcss.Declaration, + keyframeNames: Set, +) { + // Transform var(--variable-name) references to use --ock- prefix + // We need to handle nested var() calls in fallbacks + // We process from innermost to outermost by repeatedly transforming + let previousValue = ''; + let iterations = 0; + const maxIterations = 10; // Prevent infinite loops + + while (previousValue !== decl.value && iterations < maxIterations) { + previousValue = decl.value; + iterations++; + + // Match var() calls with simple variable names (no nested var in this match) + // We'll process innermost first by doing multiple passes + decl.value = decl.value.replace( + /var\((--[a-zA-Z0-9-]+)\)/g, + (match, varName) => { + // If the variable doesn't already have --ock- prefix, add it + if (!varName.startsWith('--ock-')) { + return `var(--ock-${varName.slice(2)})`; + } + return match; + }, + ); + + // Match var() calls with fallbacks (including empty fallbacks like var(--foo,)) + // This will catch var(--foo, value) or var(--foo,) + decl.value = decl.value.replace( + /var\((--[a-zA-Z0-9-]+),\s*([^)]*)\)/g, + (match, varName, fallback) => { + // If fallback contains var(, skip it for now (will be handled in next iteration) + if (fallback && fallback.includes('var(')) { + // Just prefix the variable name if needed + if (!varName.startsWith('--ock-')) { + return `var(--ock-${varName.slice(2)}, ${fallback})`; + } + return match; + } + + // If the variable doesn't already have --ock- prefix, add it + if (!varName.startsWith('--ock-')) { + const prefixedVar = `--ock-${varName.slice(2)}`; + return fallback + ? `var(${prefixedVar}, ${fallback})` + : `var(${prefixedVar},)`; + } + return match; + }, + ); + } + + // Transform animation references to use ock- prefix + transformAnimationReferences(decl, keyframeNames); +} + +function transformAnimationReferences( + decl: postcss.Declaration, + keyframeNames: Set, +) { + // Transform animation and animation-name properties to use ock- prefix + if (decl.prop === 'animation' || decl.prop === 'animation-name') { + // Handle animation values that reference keyframes directly + for (const keyframe of keyframeNames) { + // Use word boundaries to avoid partial matches + const regex = new RegExp(`\\b${keyframe}\\b`, 'g'); + if (regex.test(decl.value) && !decl.value.includes(`ock-${keyframe}`)) { + decl.value = decl.value.replace(regex, `ock-${keyframe}`); + } + } + } + + // Also handle CSS variable values that contain keyframe references + // This includes both --ock- prefixed and non-prefixed variables + // But skip variables that are already prefixed with --ock- to avoid double-prefixing + if ( + decl.prop.startsWith('--') && + !decl.prop.startsWith('--ock-') && + decl.prop.includes('animate') + ) { + for (const keyframe of keyframeNames) { + const regex = new RegExp(`\\b${keyframe}\\b`, 'g'); + if (regex.test(decl.value) && !decl.value.includes(`ock-${keyframe}`)) { + decl.value = decl.value.replace(regex, `ock-${keyframe}`); + } + } + } +} + +function transformRuleInSupports( + rule: Rule, + scopeClass: string, + keyframeNames: Set, +) { + // Transform variable declarations in @supports rules + rule.walkDecls((decl) => { + // Transform variable declarations to have --ock- prefix + if (decl.prop.startsWith('--') && !decl.prop.startsWith('--ock-')) { + decl.prop = `--ock-${decl.prop.slice(2)}`; + } + + // Transform variable references + transformVariableReferences(decl, keyframeNames); + }); + + // Transform selectors in @supports rules to be scoped + transformGlobalSelector(rule, scopeClass); +} + +function transformGlobalSelector(rule: Rule, scopeClass: string) { + // Skip selectors that are already scoped with .ock: prefix + if (rule.selector.includes('.ock\\:') || rule.selector.includes('ock:')) { + return; + } + + // Skip theme selectors - they should remain global to work with html[data-ock-theme] + if (rule.selector.includes('[data-ock-theme')) { + return; + } + + // Skip nested selectors that reference parent with & + if (rule.selector.includes('&')) { + return; + } + + // Transform global selectors to be scoped + rule.selector = splitSelectors(rule.selector) + .map((selector) => { + const trimmed = selector.trim(); + + // Transform common global selectors with :where() for low specificity + if (trimmed === '*') { + return `*:where(${scopeClass})`; + } + + if ( + trimmed === '::before' || + trimmed === '::after' || + trimmed === '::backdrop' || + trimmed === '::file-selector-button' + ) { + return `:where(${scopeClass})${trimmed}`; + } + + // Transform functional pseudo-classes like ':where()', ':is()', ':not()', etc. + // These should be combined directly with scope class without modifying their internal content + if ( + trimmed.startsWith(':where(') || + trimmed.startsWith(':is(') || + trimmed.startsWith(':not(') || + trimmed.startsWith(':has(') + ) { + return `${scopeClass}${trimmed}`; + } + + // Transform pure pseudo-selectors and pseudo-elements like ':-moz-focusring', '::-moz-placeholder', etc. + if (trimmed.startsWith('::') || trimmed.startsWith(':')) { + return `${scopeClass}${trimmed}`; + } + + // Special handling for html and :host selectors - these should apply to .ock:el directly + if (trimmed === 'html' || trimmed === ':host') { + return scopeClass; + } + + // Transform element selectors like 'body', 'hr', 'h1', 'h2', etc. + if (/^[a-zA-Z][a-zA-Z0-9]*$/.test(trimmed)) { + return `${trimmed}:where(${scopeClass})`; // Use :where() to avoid specificity issues + } + + // Transform element selectors with pseudo-selectors like 'abbr:where([title])', 'input:where([type="button"])', etc. + const elementWithPseudoMatch = trimmed.match( + /^([a-zA-Z][a-zA-Z0-9]*)(.*)$/, + ); + if (elementWithPseudoMatch && /^[a-zA-Z]/.test(trimmed)) { + const [, elementName, pseudoPart] = elementWithPseudoMatch; + // Check if the pseudo part contains typical pseudo-selectors + if ( + pseudoPart && + (pseudoPart.startsWith(':') || pseudoPart.startsWith('[')) + ) { + return `${elementName}:where(${scopeClass})${pseudoPart}`; + } + } + + // Transform more complex selectors that start with elements (fallback for other patterns) + if (/^[a-zA-Z]/.test(trimmed)) { + return `${scopeClass} ${trimmed}`; + } + + // For other selectors, scope them as descendants + return `${scopeClass} ${trimmed}`; + }) + .join(', '); +} + +function splitSelectors(selector: string): string[] { + const selectors: string[] = []; + let current = ''; + let depth = 0; + let inParens = false; + + for (let i = 0; i < selector.length; i++) { + const char = selector[i]; + + if (char === '(') { + depth++; + inParens = true; + } else if (char === ')') { + depth--; + if (depth === 0) { + inParens = false; + } + } + + if (char === ',' && depth === 0 && !inParens) { + // This comma is not inside parentheses, so it's a real selector separator + selectors.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + // Add the last selector + if (current.trim()) { + selectors.push(current.trim()); + } + + return selectors; +} + +// Required for PostCSS v8+ +postcssCreateScopedStyles.postcss = true; + +export default postcssCreateScopedStyles; diff --git a/packages/onchainkit/plugins/vite-dual-css.ts b/packages/onchainkit/plugins/vite-dual-css.ts new file mode 100644 index 0000000000..8dc008386f --- /dev/null +++ b/packages/onchainkit/plugins/vite-dual-css.ts @@ -0,0 +1,54 @@ +import type { Plugin } from 'vite'; +import postcss from 'postcss'; +import path from 'node:path'; +import fs from 'fs'; +import postcssCreateScopedStyles from './postcss-create-scoped-styles.js'; + +interface DualCSSOptions { + scopedFileName: string; +} + +export function dualCSSPlugin({ scopedFileName }: DualCSSOptions): Plugin { + return { + name: 'dual-css', + async writeBundle(outputOptions, bundle) { + console.log('📦 Generating scoped styles...'); + + // Find the main CSS file in the bundle + const cssAsset = Object.values(bundle).find( + (asset) => + asset.type === 'asset' && asset.fileName === 'assets/style.css', + ); + + if (!cssAsset || cssAsset.type !== 'asset') { + console.warn('⚠️ No style.css found in bundle'); + return; + } + + try { + // Process the CSS with our scoping plugin and layer consolidation + const result = await postcss([ + postcssCreateScopedStyles({ + consolidateLayers: true, // Enable layer consolidation for scoped styles + }), + ]).process(cssAsset.source as string, { from: undefined }); + + // Write the scoped CSS file + const outputDir = + outputOptions.dir || path.dirname(outputOptions.file || ''); + const scopedPath = path.join(outputDir, 'assets', scopedFileName); + + // Ensure the assets directory exists + const assetsDir = path.dirname(scopedPath); + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + + fs.writeFileSync(scopedPath, result.css); + console.log(`✅ Generated scoped styles: assets/${scopedFileName}`); + } catch (error) { + console.error('❌ Error generating scoped styles:', error); + } + }, + }; +} diff --git a/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx b/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx index 818d2e99bf..515b61aef1 100644 --- a/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx +++ b/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx @@ -98,7 +98,10 @@ export function AmountInput({ >
-
+