diff --git a/.changepacks/changepack_log_LKmtWOFCg17flWN8xOHz2.json b/.changepacks/changepack_log_LKmtWOFCg17flWN8xOHz2.json new file mode 100644 index 00000000..4c839921 --- /dev/null +++ b/.changepacks/changepack_log_LKmtWOFCg17flWN8xOHz2.json @@ -0,0 +1,11 @@ +{ + "changes": { + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Implement prefix option", + "date": "2025-12-29T06:55:43.116377700Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index a73a0784..1e8e684e 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -101,6 +101,56 @@ pub fn is_debug() -> bool { css::debug::is_debug() } +/// Set the CSS class name prefix +/// +/// # Example (Vite Config) +/// ```javascript +/// import init, { setPrefix, codeExtract } from 'devup-ui-wasm'; +/// +/// export default { +/// plugins: [ +/// { +/// name: 'devup-ui', +/// apply: 'pre', +/// async configResolved() { +/// await init(); +/// setPrefix('du-'); // Set prefix to 'du-' +/// }, +/// // ... other plugin code +/// } +/// ] +/// } +/// ``` +/// +/// # Example (Next.js Plugin) +/// ```typescript +/// import init, { setPrefix } from 'devup-ui-wasm'; +/// +/// const withDevupUI = (nextConfig) => { +/// return { +/// ...nextConfig, +/// webpack: (config, options) => { +/// if (!options.isServer && !global.devupUIInitialized) { +/// init().then(() => { +/// setPrefix('du-'); +/// global.devupUIInitialized = true; +/// }); +/// } +/// return config; +/// } +/// }; +/// }; +/// ``` +#[wasm_bindgen(js_name = "setPrefix")] +pub fn set_prefix(prefix: Option) { + css::set_prefix(prefix); +} + +#[wasm_bindgen(js_name = "getPrefix")] +pub fn get_prefix() -> Option { + css::get_prefix() +} + #[wasm_bindgen(js_name = "importSheet")] pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { *GLOBAL_STYLE_SHEET.lock().unwrap() = serde_wasm_bindgen::from_value(sheet_object) @@ -517,6 +567,16 @@ mod tests { assert!(!is_debug()); } + #[test] + #[serial] + fn test_prefix() { + assert_eq!(get_prefix(), None); + set_prefix(Some("du-".to_string())); + assert_eq!(get_prefix(), Some("du-".to_string())); + set_prefix(None); + assert_eq!(get_prefix(), None); + } + #[test] #[serial] fn test_default_theme() { diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index acdbeaef..24d614f5 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -11,8 +11,10 @@ mod selector_separator; pub mod style_selector; pub mod utils; +use once_cell::sync::Lazy; use std::collections::BTreeMap; use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Mutex; use crate::class_map::GLOBAL_CLASS_MAP; use crate::constant::{ @@ -25,6 +27,16 @@ use crate::optimize_value::optimize_value; use crate::style_selector::StyleSelector; use crate::utils::to_kebab_case; +static GLOBAL_PREFIX: Lazy>> = Lazy::new(|| Mutex::new(None)); + +pub fn set_prefix(prefix: Option) { + *GLOBAL_PREFIX.lock().unwrap() = prefix; +} + +pub fn get_prefix() -> Option { + GLOBAL_PREFIX.lock().unwrap().clone() +} + pub fn merge_selector(class_name: &str, selector: Option<&StyleSelector>) -> String { if let Some(selector) = selector { match selector { @@ -114,8 +126,9 @@ pub fn get_enum_property_map(property: &str) -> Option) -> String { + let prefix = get_prefix().unwrap_or_default(); if is_debug() { - format!("k-{keyframes}") + format!("{}k-{keyframes}", prefix) } else { let key = format!("k-{keyframes}"); let mut map = GLOBAL_CLASS_MAP.lock().unwrap(); @@ -133,12 +146,13 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }); if !filename.is_empty() { format!( - "{}-{}", + "{}{}-{}", + prefix, num_to_nm_base(get_file_num_by_filename(&filename)), class_num ) } else { - class_num + format!("{}{}", prefix, class_num) } } } @@ -151,6 +165,7 @@ pub fn sheet_to_classname( style_order: Option, filename: Option<&str>, ) -> String { + let prefix = get_prefix().unwrap_or_default(); // base style let filename = if style_order == Some(0) { None @@ -160,7 +175,8 @@ pub fn sheet_to_classname( if is_debug() { let selector = selector.unwrap_or_default().trim(); format!( - "{}-{}-{}-{}-{}{}", + "{}{}-{}-{}-{}-{}{}", + prefix, property.trim(), level, optimize_value(value.unwrap_or_default()), @@ -203,21 +219,24 @@ pub fn sheet_to_classname( }); if !filename.is_empty() { format!( - "{}-{}", + "{}{}-{}", + prefix, num_to_nm_base(get_file_num_by_filename(&filename)), clas_num ) } else { - clas_num + format!("{}{}", prefix, clas_num) } } } pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { + let prefix = get_prefix().unwrap_or_default(); if is_debug() { let selector = selector.unwrap_or_default().trim(); format!( - "--{}-{}-{}", + "--{}{}-{}-{}", + prefix, property, level, if selector.is_empty() { @@ -239,12 +258,12 @@ pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) map.entry("".to_string()) .or_default() .get(&key) - .map(|v| format!("--{}", num_to_nm_base(*v))) + .map(|v| format!("--{}{}", prefix, num_to_nm_base(*v))) .unwrap_or_else(|| { let m = map.entry("".to_string()).or_default(); let len = m.len(); m.insert(key, len); - format!("--{}", num_to_nm_base(len)) + format!("--{}{}", prefix, num_to_nm_base(len)) }) } } @@ -718,4 +737,80 @@ mod tests { } ); } + + #[test] + #[serial] + fn test_sheet_to_classname_with_prefix() { + set_debug(false); + reset_class_map(); + set_prefix(Some("app-".to_string())); + + let class1 = sheet_to_classname("background", 0, Some("red"), None, None, None); + assert!(class1.starts_with("app-")); + assert_eq!(class1, "app-a"); + + let class2 = sheet_to_classname("color", 0, Some("blue"), None, None, None); + assert!(class2.starts_with("app-")); + + set_prefix(None); + reset_class_map(); + } + + #[test] + #[serial] + fn test_debug_sheet_to_classname_with_prefix() { + set_debug(true); + set_prefix(Some("my-".to_string())); + + let class_name = sheet_to_classname("background", 0, Some("red"), None, None, None); + assert_eq!(class_name, "my-background-0-red--255"); + + let with_selector = + sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); + assert!(with_selector.starts_with("my-")); + + set_prefix(None); + } + + #[test] + #[serial] + fn test_sheet_to_variable_name_with_prefix() { + set_debug(false); + reset_class_map(); + set_prefix(Some("app-".to_string())); + + assert_eq!(sheet_to_variable_name("background", 0, None), "--app-a"); + + set_prefix(None); + reset_class_map(); + } + + #[test] + #[serial] + fn test_keyframes_with_prefix() { + reset_class_map(); + set_debug(false); + set_prefix(Some("app-".to_string())); + + let name = keyframes_to_keyframes_name("spin", None); + assert!(name.starts_with("app-")); + + set_prefix(None); + } + + #[test] + #[serial] + fn test_empty_prefix_is_same_as_none() { + set_debug(false); + reset_class_map(); + + set_prefix(Some("".to_string())); + let class1 = sheet_to_classname("background", 0, Some("red"), None, None, None); + + reset_class_map(); + set_prefix(None); + let class2 = sheet_to_classname("background", 0, Some("red"), None, None, None); + + assert_eq!(class1, class2); + } } diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 113aebc5..86b2b3a9 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,456 +1,467 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' - -import { getDefaultTheme, getThemeInterface } from '@devup-ui/wasm' -import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin' - -import { DevupUI } from '../plugin' -import { preload } from '../preload' - -vi.mock('@devup-ui/webpack-plugin') -vi.mock('node:fs') -vi.mock('../preload') -vi.mock('@devup-ui/wasm', async (original) => ({ - ...(await original()), - registerTheme: vi.fn(), - getThemeInterface: vi.fn(), - getDefaultTheme: vi.fn(), - getCss: vi.fn(() => ''), - exportSheet: vi.fn(() => - JSON.stringify({ - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }), - ), - exportClassMap: vi.fn(() => JSON.stringify({})), - exportFileMap: vi.fn(() => JSON.stringify({})), -})) - -describe('DevupUINextPlugin', () => { - describe('webpack', () => { - it('should apply webpack plugin', async () => { - const ret = DevupUI({}) - - ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) - - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - }) - - it('should apply webpack plugin with dev', async () => { - const ret = DevupUI({}) - - ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId', dev: true } as any) - - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - cssDir: resolve('df', 'devup-ui_tmpBuildId'), - watch: true, - }) - }) - - it('should apply webpack plugin with config', async () => { - const ret = DevupUI( - {}, - { - package: 'new-package', - }, - ) - - ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) - - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - }) - - it('should apply webpack plugin with webpack obj', async () => { - const webpack = vi.fn() - const ret = DevupUI( - { - webpack, - }, - { - package: 'new-package', - }, - ) - - ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) - - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - expect(webpack).toHaveBeenCalled() - }) - }) - describe('turbo', () => { - beforeEach(() => { - // Mock fetch globally to prevent "http://localhost:undefined" errors - global.fetch = vi.fn(() => Promise.resolve({} as Response)) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('should apply turbo config', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - }, - }, - ], - '*.{tsx,ts,js,mjs}': { - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - themeFile: 'devup.json', - watch: false, - singleCss: false, - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - }, - }, - ], - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - }, - }, - }, - }) - }) - it('should apply turbo config with create df', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.mocked(existsSync).mockReturnValue(false) - vi.mocked(mkdirSync).mockReturnValue('') - vi.mocked(writeFileSync).mockReturnValue() - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - }, - }, - ], - '*.{tsx,ts,js,mjs}': { - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - watch: false, - singleCss: false, - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - themeFile: 'devup.json', - }, - }, - ], - }, - }, - }, - }) - expect(mkdirSync).toHaveBeenCalledWith('df', { - recursive: true, - }) - expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') - }) - it('should apply turbo config with exists df and devup.json', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ theme: 'theme' }), - ) - vi.mocked(mkdirSync).mockReturnValue('') - vi.mocked(writeFileSync).mockReturnValue() - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - }, - }, - ], - '*.{tsx,ts,js,mjs}': { - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - watch: false, - singleCss: false, - theme: 'theme', - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - themeFile: 'devup.json', - }, - }, - ], - }, - }, - }, - }) - expect(mkdirSync).toHaveBeenCalledWith('df', { - recursive: true, - }) - expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') - }) - it('should throw error if NODE_ENV is production', () => { - vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('TURBOPACK', '1') - vi.mocked(preload).mockReturnValue() - const ret = DevupUI({}) - expect(ret).toEqual({ - turbopack: { - rules: expect.any(Object), - }, - }) - expect(preload).toHaveBeenCalledWith( - new RegExp( - `(node_modules(?!.*(${['@devup-ui'] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - '@devup-ui/react', - false, - expect.any(String), - [], - ) - }) - it('should create theme.d.ts file', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ theme: 'theme' }), - ) - vi.mocked(mkdirSync).mockReturnValue('') - vi.mocked(writeFileSync).mockReturnValue() - DevupUI({}) - expect(writeFileSync).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface code', - ) - expect(mkdirSync).toHaveBeenCalledWith('df', { - recursive: true, - }) - expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') - }) - it('should set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns a value', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - vi.mocked(getDefaultTheme).mockReturnValue('dark') - const config: any = {} - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('dark') - expect(ret.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - }) - expect(config.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - }) - }) - it('should not set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns undefined', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - vi.mocked(getDefaultTheme).mockReturnValue(undefined) - const config: any = {} - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('') - expect(ret.env).toBeUndefined() - expect(config.env).toBeUndefined() - }) - it('should set DEVUP_UI_DEFAULT_THEME and preserve existing env vars', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - vi.mocked(getDefaultTheme).mockReturnValue('light') - const config: any = { - env: { - CUSTOM_VAR: 'value', - }, - } - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('light') - expect(ret.env).toEqual({ - CUSTOM_VAR: 'value', - DEVUP_UI_DEFAULT_THEME: 'light', - }) - expect(config.env).toEqual({ - CUSTOM_VAR: 'value', - DEVUP_UI_DEFAULT_THEME: 'light', - }) - }) - it('should handle debugPort fetch failure in development mode', async () => { - vi.stubEnv('TURBOPACK', '1') - vi.stubEnv('NODE_ENV', 'development') - vi.stubEnv('PORT', '3000') - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - vi.mocked(writeFileSync).mockReturnValue() - - // Mock process.exit to prevent actual exit - const originalExit = process.exit - const exitSpy = vi.fn() - process.exit = exitSpy as any - - // Mock process.debugPort - const originalDebugPort = process.debugPort - process.debugPort = 9229 - - // Mock fetch globally before calling DevupUI - const originalFetch = global.fetch - const fetchMock = vi.fn((url: string | URL) => { - const urlString = typeof url === 'string' ? url : url.toString() - if (urlString.includes('9229')) { - return Promise.reject(new Error('Connection refused')) - } - return Promise.resolve({} as Response) - }) - global.fetch = fetchMock as any - - // Use fake timers to control setTimeout - vi.useFakeTimers() - - try { - DevupUI({}) - - // Wait for the fetch promise to reject (this triggers the catch handler) - // The catch handler sets up a setTimeout, so we need to wait for that - await vi.runAllTimersAsync() - - // Verify process.exit was called with code 77 - expect(exitSpy).toHaveBeenCalledWith(77) - } finally { - // Restore - vi.useRealTimers() - global.fetch = originalFetch - process.exit = originalExit - process.debugPort = originalDebugPort - } - }) - }) -}) +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { getDefaultTheme, getThemeInterface, setPrefix } from '@devup-ui/wasm' +import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin' + +import { DevupUI } from '../plugin' +import { preload } from '../preload' + +vi.mock('@devup-ui/webpack-plugin') +vi.mock('node:fs') +vi.mock('../preload') +vi.mock('@devup-ui/wasm', async (original) => ({ + ...(await original()), + registerTheme: vi.fn(), + getThemeInterface: vi.fn(), + getDefaultTheme: vi.fn(), + getCss: vi.fn(() => ''), + setPrefix: vi.fn(), + exportSheet: vi.fn(() => + JSON.stringify({ + css: {}, + font_faces: {}, + global_css_files: [], + imports: {}, + keyframes: {}, + properties: {}, + }), + ), + exportClassMap: vi.fn(() => JSON.stringify({})), + exportFileMap: vi.fn(() => JSON.stringify({})), +})) + +describe('DevupUINextPlugin', () => { + describe('webpack', () => { + it('should apply webpack plugin', async () => { + const ret = DevupUI({}) + + ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) + + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }) + }) + + it('should apply webpack plugin with dev', async () => { + const ret = DevupUI({}) + + ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId', dev: true } as any) + + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ + cssDir: resolve('df', 'devup-ui_tmpBuildId'), + watch: true, + }) + }) + + it('should apply webpack plugin with config', async () => { + const ret = DevupUI( + {}, + { + package: 'new-package', + }, + ) + + ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) + + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ + package: 'new-package', + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }) + }) + + it('should apply webpack plugin with webpack obj', async () => { + const webpack = vi.fn() + const ret = DevupUI( + { + webpack, + }, + { + package: 'new-package', + }, + ) + + ret.webpack!({ plugins: [] }, { buildId: 'tmpBuildId' } as any) + + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ + package: 'new-package', + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }) + expect(webpack).toHaveBeenCalled() + }) + }) + describe('turbo', () => { + beforeEach(() => { + // Mock fetch globally to prevent "http://localhost:undefined" errors + global.fetch = vi.fn(() => Promise.resolve({} as Response)) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should apply turbo config', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + const ret = DevupUI({}) + + expect(ret).toEqual({ + turbopack: { + rules: { + './df/devup-ui/*.css': [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: { + watch: false, + }, + }, + ], + '*.{tsx,ts,js,mjs}': { + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: { + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + sheetFile: join('df', 'sheet.json'), + classMapFile: join('df', 'classMap.json'), + fileMapFile: join('df', 'fileMap.json'), + themeFile: 'devup.json', + watch: false, + singleCss: false, + theme: {}, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: { + css: {}, + font_faces: {}, + global_css_files: [], + imports: {}, + keyframes: {}, + properties: {}, + }, + }, + }, + ], + condition: { + not: { + path: new RegExp( + `(node_modules(?!.*(${['@devup-ui'] + .join('|') + .replaceAll( + '/', + '[\\/\\\\_]', + )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ), + }, + }, + }, + }, + }, + }) + }) + it('should apply turbo config with create df', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(mkdirSync).mockReturnValue('') + vi.mocked(writeFileSync).mockReturnValue() + const ret = DevupUI({}) + + expect(ret).toEqual({ + turbopack: { + rules: { + './df/devup-ui/*.css': [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: { + watch: false, + }, + }, + ], + '*.{tsx,ts,js,mjs}': { + condition: { + not: { + path: new RegExp( + `(node_modules(?!.*(${['@devup-ui'] + .join('|') + .replaceAll( + '/', + '[\\/\\\\_]', + )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ), + }, + }, + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: { + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + sheetFile: join('df', 'sheet.json'), + classMapFile: join('df', 'classMap.json'), + fileMapFile: join('df', 'fileMap.json'), + watch: false, + singleCss: false, + theme: {}, + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: { + css: {}, + font_faces: {}, + global_css_files: [], + imports: {}, + keyframes: {}, + properties: {}, + }, + themeFile: 'devup.json', + }, + }, + ], + }, + }, + }, + }) + expect(mkdirSync).toHaveBeenCalledWith('df', { + recursive: true, + }) + expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') + }) + it('should apply turbo config with exists df and devup.json', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ theme: 'theme' }), + ) + vi.mocked(mkdirSync).mockReturnValue('') + vi.mocked(writeFileSync).mockReturnValue() + const ret = DevupUI({}) + + expect(ret).toEqual({ + turbopack: { + rules: { + './df/devup-ui/*.css': [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: { + watch: false, + }, + }, + ], + '*.{tsx,ts,js,mjs}': { + condition: { + not: { + path: new RegExp( + `(node_modules(?!.*(${['@devup-ui'] + .join('|') + .replaceAll( + '/', + '[\\/\\\\_]', + )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ), + }, + }, + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: { + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + sheetFile: join('df', 'sheet.json'), + classMapFile: join('df', 'classMap.json'), + fileMapFile: join('df', 'fileMap.json'), + watch: false, + singleCss: false, + theme: 'theme', + defaultClassMap: {}, + defaultFileMap: {}, + defaultSheet: { + css: {}, + font_faces: {}, + global_css_files: [], + imports: {}, + keyframes: {}, + properties: {}, + }, + themeFile: 'devup.json', + }, + }, + ], + }, + }, + }, + }) + expect(mkdirSync).toHaveBeenCalledWith('df', { + recursive: true, + }) + expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') + }) + it('should throw error if NODE_ENV is production', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('TURBOPACK', '1') + vi.mocked(preload).mockReturnValue() + const ret = DevupUI({}) + expect(ret).toEqual({ + turbopack: { + rules: expect.any(Object), + }, + }) + expect(preload).toHaveBeenCalledWith( + new RegExp( + `(node_modules(?!.*(${['@devup-ui'] + .join('|') + .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ), + '@devup-ui/react', + false, + expect.any(String), + [], + ) + }) + it('should create theme.d.ts file', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ theme: 'theme' }), + ) + vi.mocked(mkdirSync).mockReturnValue('') + vi.mocked(writeFileSync).mockReturnValue() + DevupUI({}) + expect(writeFileSync).toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + 'interface code', + ) + expect(mkdirSync).toHaveBeenCalledWith('df', { + recursive: true, + }) + expect(writeFileSync).toHaveBeenCalledWith(join('df', '.gitignore'), '*') + }) + it('should set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns a value', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + vi.mocked(getDefaultTheme).mockReturnValue('dark') + const config: any = {} + const ret = DevupUI(config) + + expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('dark') + expect(ret.env).toEqual({ + DEVUP_UI_DEFAULT_THEME: 'dark', + }) + expect(config.env).toEqual({ + DEVUP_UI_DEFAULT_THEME: 'dark', + }) + }) + it('should not set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns undefined', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + vi.mocked(getDefaultTheme).mockReturnValue(undefined) + const config: any = {} + const ret = DevupUI(config) + + expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('') + expect(ret.env).toBeUndefined() + expect(config.env).toBeUndefined() + }) + it('should set DEVUP_UI_DEFAULT_THEME and preserve existing env vars', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.stubEnv('DEVUP_UI_DEFAULT_THEME', '') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + vi.mocked(getDefaultTheme).mockReturnValue('light') + const config: any = { + env: { + CUSTOM_VAR: 'value', + }, + } + const ret = DevupUI(config) + + expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('light') + expect(ret.env).toEqual({ + CUSTOM_VAR: 'value', + DEVUP_UI_DEFAULT_THEME: 'light', + }) + expect(config.env).toEqual({ + CUSTOM_VAR: 'value', + DEVUP_UI_DEFAULT_THEME: 'light', + }) + }) + it('should call setPrefix when prefix option is provided', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + DevupUI({}, { prefix: 'my-prefix' }) + expect(setPrefix).toHaveBeenCalledWith('my-prefix') + }) + it('should handle debugPort fetch failure in development mode', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('PORT', '3000') + vi.mocked(existsSync) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + vi.mocked(writeFileSync).mockReturnValue() + + // Mock process.exit to prevent actual exit + const originalExit = process.exit + const exitSpy = vi.fn() + process.exit = exitSpy as any + + // Mock process.debugPort + const originalDebugPort = process.debugPort + process.debugPort = 9229 + + // Mock fetch globally before calling DevupUI + const originalFetch = global.fetch + const fetchMock = vi.fn((url: string | URL) => { + const urlString = typeof url === 'string' ? url : url.toString() + if (urlString.includes('9229')) { + return Promise.reject(new Error('Connection refused')) + } + return Promise.resolve({} as Response) + }) + global.fetch = fetchMock as any + + // Use fake timers to control setTimeout + vi.useFakeTimers() + + try { + DevupUI({}) + + // Wait for the fetch promise to reject (this triggers the catch handler) + // The catch handler sets up a setTimeout, so we need to wait for that + await vi.runAllTimersAsync() + + // Verify process.exit was called with code 77 + expect(exitSpy).toHaveBeenCalledWith(77) + } finally { + // Restore + vi.useRealTimers() + global.fetch = originalFetch + process.exit = originalExit + process.debugPort = originalDebugPort + } + }) + }) +}) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index abda68e8..949580c0 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,172 +1,178 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' - -import { - exportClassMap, - exportFileMap, - exportSheet, - getCss, - getDefaultTheme, - getThemeInterface, - registerTheme, -} from '@devup-ui/wasm' -import { - DevupUIWebpackPlugin, - type DevupUIWebpackPluginOptions, -} from '@devup-ui/webpack-plugin' -import { type NextConfig } from 'next' - -import { preload } from './preload' - -type DevupUiNextPluginOptions = Omit< - Partial, - 'watch' -> - -/** - * Devup UI Next Plugin - * @param config - * @param options - * @constructor - */ -export function DevupUI( - config: NextConfig, - options: DevupUiNextPluginOptions = {}, -): NextConfig { - const isTurbo = - process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' - // turbopack is now stable, TURBOPACK is set to auto without any flags - if (isTurbo) { - config ??= {} - config.turbopack ??= {} - config.turbopack.rules ??= {} - const { - package: libPackage = '@devup-ui/react', - distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), - singleCss = false, - devupFile = 'devup.json', - include = [], - } = options - - const sheetFile = join(distDir, 'sheet.json') - const classMapFile = join(distDir, 'classMap.json') - const fileMapFile = join(distDir, 'fileMap.json') - const gitignoreFile = join(distDir, '.gitignore') - if (!existsSync(distDir)) - mkdirSync(distDir, { - recursive: true, - }) - if (!existsSync(cssDir)) - mkdirSync(cssDir, { - recursive: true, - }) - if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') - const theme = existsSync(devupFile) - ? JSON.parse(readFileSync(devupFile, 'utf-8'))?.['theme'] - : {} - registerTheme(theme) - const themeInterface = getThemeInterface( - libPackage, - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - if (themeInterface) { - writeFileSync(join(distDir, 'theme.d.ts'), themeInterface) - } - // disable turbo parallel - const excludeRegex = new RegExp( - `(node_modules(?!.*(${['@devup-ui', ...include] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ) - - if (process.env.NODE_ENV !== 'production') { - // check if debugger is attached - fetch('http://localhost:' + process.env.PORT) - fetch('http://localhost:' + process.debugPort).catch(() => { - setTimeout(() => { - process.exit(77) - }, 500) - }) - process.env.TURBOPACK_DEBUG_JS = '*' - process.env.NODE_OPTIONS ??= '' - process.env.NODE_OPTIONS += ' --inspect-brk' - // create devup-ui.css file - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) - } else { - // build - preload(excludeRegex, libPackage, singleCss, cssDir, include) - } - const defaultSheet = JSON.parse(exportSheet()) - const defaultClassMap = JSON.parse(exportClassMap()) - const defaultFileMap = JSON.parse(exportFileMap()) - // for theme script - const defaultTheme = getDefaultTheme() - if (defaultTheme) { - process.env.DEVUP_UI_DEFAULT_THEME = defaultTheme - config.env ??= {} - Object.assign(config.env, { - DEVUP_UI_DEFAULT_THEME: defaultTheme, - }) - } - - const rules: NonNullable = { - [`./${relative(process.cwd(), cssDir).replaceAll('\\', '/')}/*.css`]: [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: process.env.NODE_ENV === 'development', - }, - }, - ], - '*.{tsx,ts,js,mjs}': { - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: libPackage, - cssDir, - sheetFile, - classMapFile, - fileMapFile, - themeFile: devupFile, - defaultSheet, - defaultClassMap, - defaultFileMap, - watch: process.env.NODE_ENV === 'development', - singleCss, - // for turbopack, load theme is required on loader - theme, - }, - }, - ], - condition: { - not: { - path: excludeRegex, - }, - }, - }, - } - Object.assign(config.turbopack.rules, rules) - return config - } - - const { webpack } = config - config.webpack = (config, _options) => { - options.cssDir ??= resolve( - _options.dev ? (options.distDir ?? 'df') : '.next/cache', - `devup-ui_${_options.buildId}`, - ) - config.plugins.push( - new DevupUIWebpackPlugin({ - ...options, - watch: _options.dev, - }), - ) - if (typeof webpack === 'function') return webpack(config, _options) - return config - } - return config -} +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +import { + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getDefaultTheme, + getThemeInterface, + registerTheme, + setPrefix, +} from '@devup-ui/wasm' +import { + DevupUIWebpackPlugin, + type DevupUIWebpackPluginOptions, +} from '@devup-ui/webpack-plugin' +import { type NextConfig } from 'next' + +import { preload } from './preload' + +type DevupUiNextPluginOptions = Omit< + Partial, + 'watch' +> + +/** + * Devup UI Next Plugin + * @param config + * @param options + * @constructor + */ +export function DevupUI( + config: NextConfig, + options: DevupUiNextPluginOptions = {}, +): NextConfig { + const isTurbo = + process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' + // turbopack is now stable, TURBOPACK is set to auto without any flags + if (isTurbo) { + config ??= {} + config.turbopack ??= {} + config.turbopack.rules ??= {} + const { + package: libPackage = '@devup-ui/react', + distDir = 'df', + cssDir = resolve(distDir, 'devup-ui'), + singleCss = false, + devupFile = 'devup.json', + include = [], + prefix, + } = options + + if (prefix) { + setPrefix(prefix) + } + + const sheetFile = join(distDir, 'sheet.json') + const classMapFile = join(distDir, 'classMap.json') + const fileMapFile = join(distDir, 'fileMap.json') + const gitignoreFile = join(distDir, '.gitignore') + if (!existsSync(distDir)) + mkdirSync(distDir, { + recursive: true, + }) + if (!existsSync(cssDir)) + mkdirSync(cssDir, { + recursive: true, + }) + if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') + const theme = existsSync(devupFile) + ? JSON.parse(readFileSync(devupFile, 'utf-8'))?.['theme'] + : {} + registerTheme(theme) + const themeInterface = getThemeInterface( + libPackage, + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + if (themeInterface) { + writeFileSync(join(distDir, 'theme.d.ts'), themeInterface) + } + // disable turbo parallel + const excludeRegex = new RegExp( + `(node_modules(?!.*(${['@devup-ui', ...include] + .join('|') + .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ) + + if (process.env.NODE_ENV !== 'production') { + // check if debugger is attached + fetch('http://localhost:' + process.env.PORT) + fetch('http://localhost:' + process.debugPort).catch(() => { + setTimeout(() => { + process.exit(77) + }, 500) + }) + process.env.TURBOPACK_DEBUG_JS = '*' + process.env.NODE_OPTIONS ??= '' + process.env.NODE_OPTIONS += ' --inspect-brk' + // create devup-ui.css file + writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) + } else { + // build + preload(excludeRegex, libPackage, singleCss, cssDir, include) + } + const defaultSheet = JSON.parse(exportSheet()) + const defaultClassMap = JSON.parse(exportClassMap()) + const defaultFileMap = JSON.parse(exportFileMap()) + // for theme script + const defaultTheme = getDefaultTheme() + if (defaultTheme) { + process.env.DEVUP_UI_DEFAULT_THEME = defaultTheme + config.env ??= {} + Object.assign(config.env, { + DEVUP_UI_DEFAULT_THEME: defaultTheme, + }) + } + + const rules: NonNullable = { + [`./${relative(process.cwd(), cssDir).replaceAll('\\', '/')}/*.css`]: [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: { + watch: process.env.NODE_ENV === 'development', + }, + }, + ], + '*.{tsx,ts,js,mjs}': { + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: { + package: libPackage, + cssDir, + sheetFile, + classMapFile, + fileMapFile, + themeFile: devupFile, + defaultSheet, + defaultClassMap, + defaultFileMap, + watch: process.env.NODE_ENV === 'development', + singleCss, + // for turbopack, load theme is required on loader + theme, + }, + }, + ], + condition: { + not: { + path: excludeRegex, + }, + }, + }, + } + Object.assign(config.turbopack.rules, rules) + return config + } + + const { webpack } = config + config.webpack = (config, _options) => { + options.cssDir ??= resolve( + _options.dev ? (options.distDir ?? 'df') : '.next/cache', + `devup-ui_${_options.buildId}`, + ) + config.plugins.push( + new DevupUIWebpackPlugin({ + ...options, + watch: _options.dev, + }), + ) + if (typeof webpack === 'function') return webpack(config, _options) + return config + } + return config +} diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index f19a33b4..e2823716 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -1,379 +1,389 @@ -import { existsSync, writeFileSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' - -import { - codeExtract, - getDefaultTheme, - getThemeInterface, - registerTheme, -} from '@devup-ui/wasm' -import { vi } from 'vitest' - -import { DevupUI } from '../plugin' - -// Mock dependencies -vi.mock('node:fs/promises') -vi.mock('node:fs') -vi.mock('@devup-ui/wasm') - -describe('DevupUIRsbuildPlugin', () => { - beforeEach(() => { - vi.resetAllMocks() - vi.mocked(mkdir).mockResolvedValue(undefined) - vi.mocked(writeFile).mockResolvedValue(undefined) - }) - - it('should export DevupUIRsbuildPlugin', () => { - expect(DevupUI).toBeDefined() - }) - - it('should be a function', () => { - expect(DevupUI).toBeInstanceOf(Function) - }) - - it('should return a plugin object with correct name', async () => { - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.name).toBe('devup-ui-rsbuild-plugin') - expect(typeof plugin.setup).toBe('function') - - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - expect(transform).toHaveBeenCalled() - }) - - it('should write data files', async () => { - vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(existsSync).mockImplementation((path) => { - if (path === 'devup.json') return true - return false - }) - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - }) - - it('should write data files without theme', async () => { - vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) - vi.mocked(getThemeInterface).mockReturnValue('') - vi.mocked(existsSync).mockImplementation((path) => { - if (path === 'devup.json') return true - return false - }) - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - expect(writeFileSync).not.toHaveBeenCalled() - }) - - it('should error when write data files', async () => { - const originalConsoleError = console.error - console.error = vi.fn() - vi.mocked(readFile).mockRejectedValueOnce('error') - vi.mocked(existsSync).mockImplementation((path) => { - if (path === 'devup.json') return true - return false - }) - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - expect(console.error).toHaveBeenCalledWith('error') - console.error = originalConsoleError - }) - - it('should not register css transform', async () => { - const plugin = DevupUI({ - extractCss: false, - }) - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - await plugin.setup({ - transform, - } as any) - expect(transform).not.toHaveBeenCalled() - }) - - it('should accept custom options', () => { - const customOptions = { - package: '@custom/devup-ui', - cssFile: './custom.css', - devupPath: './custom-df', - interfacePath: './custom-interface', - extractCss: false, - debug: true, - include: ['src/**/*'], - } - - const plugin = DevupUI(customOptions) - expect(plugin).toBeDefined() - expect(plugin.name).toBe('devup-ui-rsbuild-plugin') - }) - it('should transform css', async () => { - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - expect(transform).toHaveBeenCalled() - expect(transform).toHaveBeenCalledWith( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - expect.any(Function), - ) - - expect( - transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, - }), - ).toBe('') - }) - it('should transform code', async () => { - const plugin = DevupUI() - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - const modifyRsbuildConfig = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig, - } as any) - expect(transform).toHaveBeenCalled() - expect(transform).toHaveBeenCalledWith( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - expect.any(Function), - ) - - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') - - vi.mocked(codeExtract).mockReturnValue({ - code: '
', - css: '', - css_file: 'devup-ui.css', - } as any) - await expect( - transform.mock.calls[1][1]({ - code: `import { Box } from '@devup-ui/react' -const App = () => `, - resourcePath: 'src/App.tsx', - }), - ).resolves.toEqual({ - code: '
', - map: undefined, - }) - await expect( - transform.mock.calls[1][1]({ - code: `import { Box } from '@devup-ui/react' -const App = () => `, - resourcePath: 'node_modules/@wrong-ui/react/index.tsx', - }), - ).resolves.toEqual( - `import { Box } from '@devup-ui/react' -const App = () => `, - ) - }) - it.each( - createTestMatrix({ - updatedBaseStyle: [true, false], - }), - )('should transform with include', async (options) => { - const plugin = DevupUI({ - include: ['lib'], - }) - expect(plugin).toBeDefined() - expect(plugin.setup).toBeDefined() - const transform = vi.fn() - await plugin.setup({ - transform, - modifyRsbuildConfig: vi.fn(), - } as any) - expect(transform).toHaveBeenCalled() - expect(transform).toHaveBeenCalledWith( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - expect.any(Function), - ) - vi.mocked(codeExtract).mockReturnValue({ - code: '
', - css: '.devup-ui-1 { color: red; }', - cssFile: 'devup-ui.css', - map: undefined, - updatedBaseStyle: options.updatedBaseStyle, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - const ret = await transform.mock.calls[1][1]({ - code: `import { Box } from '@devup-ui/react' -const App = () => `, - resourcePath: 'src/App.tsx', - }) - expect(ret).toEqual({ - code: '
', - map: undefined, - }) - - if (options.updatedBaseStyle) { - expect(writeFile).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } - expect(writeFile).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - - const ret1 = await transform.mock.calls[1][1]({ - code: `import { Box } from '@devup-ui/react' -const App = () => `, - resourcePath: 'node_modules/@devup-ui/react/index.tsx', - }) - expect(ret1).toEqual({ - code: `
`, - map: undefined, - }) - }) - it.each( - createTestMatrix({ - watch: [true, false], - existsDevupFile: [true, false], - existsDistDir: [true, false], - existsSheetFile: [true, false], - existsClassMapFile: [true, false], - existsFileMapFile: [true, false], - existsCssDir: [true, false], - getDefaultTheme: ['theme', ''], - singleCss: [true, false], - }), - )('should write data files', async (options) => { - vi.mocked(writeFile).mockResolvedValueOnce(undefined) - vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(getDefaultTheme).mockReturnValue(options.getDefaultTheme) - vi.mocked(existsSync).mockImplementation((path) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir - if (path === resolve('df', 'devup-ui')) return options.existsCssDir - if (path === join('df', 'sheet.json')) return options.existsSheetFile - if (path === join('df', 'classMap.json')) - return options.existsClassMapFile - if (path === join('df', 'fileMap.json')) return options.existsFileMapFile - return false - }) - const plugin = DevupUI({ singleCss: options.singleCss }) - await (plugin as any).setup({ - transform: vi.fn(), - renderChunk: vi.fn(), - generateBundle: vi.fn(), - closeBundle: vi.fn(), - resolve: vi.fn(), - load: vi.fn(), - modifyRsbuildConfig: vi.fn(), - watchChange: vi.fn(), - resolveId: vi.fn(), - } as any) - if (options.existsDevupFile) { - expect(readFile).toHaveBeenCalledWith('devup.json', 'utf-8') - expect(registerTheme).toHaveBeenCalledWith({}) - expect(getThemeInterface).toHaveBeenCalledWith( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - expect(writeFile).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface code', - 'utf-8', - ) - } else { - expect(registerTheme).toHaveBeenCalledWith({}) - } - - const modifyRsbuildConfig = vi.fn() - await (plugin as any).setup({ - transform: vi.fn(), - renderChunk: vi.fn(), - generateBundle: vi.fn(), - closeBundle: vi.fn(), - resolve: vi.fn(), - modifyRsbuildConfig, - load: vi.fn(), - watchChange: vi.fn(), - resolveId: vi.fn(), - } as any) - if (options.getDefaultTheme) { - expect(modifyRsbuildConfig).toHaveBeenCalledWith(expect.any(Function)) - const config = { - source: { - define: {}, - }, - } - modifyRsbuildConfig.mock.calls[0][0](config) - expect(config).toEqual({ - source: { - define: { - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( - options.getDefaultTheme, - ), - }, - }, - }) - } else { - expect(modifyRsbuildConfig).toHaveBeenCalledWith(expect.any(Function)) - const config = { - source: { - define: {}, - }, - } - modifyRsbuildConfig.mock.calls[0][0](config) - expect(config).toEqual({ - source: { - define: {}, - }, - }) - } - }) -}) +import { existsSync, writeFileSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { + codeExtract, + getDefaultTheme, + getThemeInterface, + registerTheme, + setPrefix, +} from '@devup-ui/wasm' +import { vi } from 'vitest' + +import { DevupUI } from '../plugin' + +// Mock dependencies +vi.mock('node:fs/promises') +vi.mock('node:fs') +vi.mock('@devup-ui/wasm') + +describe('DevupUIRsbuildPlugin', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(mkdir).mockResolvedValue(undefined) + vi.mocked(writeFile).mockResolvedValue(undefined) + }) + + it('should export DevupUIRsbuildPlugin', () => { + expect(DevupUI).toBeDefined() + }) + + it('should be a function', () => { + expect(DevupUI).toBeInstanceOf(Function) + }) + + it('should return a plugin object with correct name', async () => { + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.name).toBe('devup-ui-rsbuild-plugin') + expect(typeof plugin.setup).toBe('function') + + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + expect(transform).toHaveBeenCalled() + }) + + it('should write data files', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(existsSync).mockImplementation((path) => { + if (path === 'devup.json') return true + return false + }) + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + }) + + it('should write data files without theme', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) + vi.mocked(getThemeInterface).mockReturnValue('') + vi.mocked(existsSync).mockImplementation((path) => { + if (path === 'devup.json') return true + return false + }) + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + expect(writeFileSync).not.toHaveBeenCalled() + }) + + it('should error when write data files', async () => { + const originalConsoleError = console.error + console.error = vi.fn() + vi.mocked(readFile).mockRejectedValueOnce('error') + vi.mocked(existsSync).mockImplementation((path) => { + if (path === 'devup.json') return true + return false + }) + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + expect(console.error).toHaveBeenCalledWith('error') + console.error = originalConsoleError + }) + + it('should not register css transform', async () => { + const plugin = DevupUI({ + extractCss: false, + }) + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + await plugin.setup({ + transform, + } as any) + expect(transform).not.toHaveBeenCalled() + }) + + it('should accept custom options', () => { + const customOptions = { + package: '@custom/devup-ui', + cssFile: './custom.css', + devupPath: './custom-df', + interfacePath: './custom-interface', + extractCss: false, + debug: true, + include: ['src/**/*'], + } + + const plugin = DevupUI(customOptions) + expect(plugin).toBeDefined() + expect(plugin.name).toBe('devup-ui-rsbuild-plugin') + }) + it('should transform css', async () => { + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + expect(transform).toHaveBeenCalled() + expect(transform).toHaveBeenCalledWith( + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + }, + expect.any(Function), + ) + + expect( + transform.mock.calls[0][1]({ + code: ` + .devup-ui-1 { + color: red; + } + `, + }), + ).toBe('') + }) + it('should transform code', async () => { + const plugin = DevupUI() + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + const modifyRsbuildConfig = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig, + } as any) + expect(transform).toHaveBeenCalled() + expect(transform).toHaveBeenCalledWith( + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + }, + expect.any(Function), + ) + + expect( + transform.mock.calls[0][1]({ + code: ``, + }), + ).toBe('') + + vi.mocked(codeExtract).mockReturnValue({ + code: '
', + css: '', + css_file: 'devup-ui.css', + } as any) + await expect( + transform.mock.calls[1][1]({ + code: `import { Box } from '@devup-ui/react' +const App = () => `, + resourcePath: 'src/App.tsx', + }), + ).resolves.toEqual({ + code: '
', + map: undefined, + }) + await expect( + transform.mock.calls[1][1]({ + code: `import { Box } from '@devup-ui/react' +const App = () => `, + resourcePath: 'node_modules/@wrong-ui/react/index.tsx', + }), + ).resolves.toEqual( + `import { Box } from '@devup-ui/react' +const App = () => `, + ) + }) + it.each( + createTestMatrix({ + updatedBaseStyle: [true, false], + }), + )('should transform with include', async (options) => { + const plugin = DevupUI({ + include: ['lib'], + }) + expect(plugin).toBeDefined() + expect(plugin.setup).toBeDefined() + const transform = vi.fn() + await plugin.setup({ + transform, + modifyRsbuildConfig: vi.fn(), + } as any) + expect(transform).toHaveBeenCalled() + expect(transform).toHaveBeenCalledWith( + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + }, + expect.any(Function), + ) + vi.mocked(codeExtract).mockReturnValue({ + code: '
', + css: '.devup-ui-1 { color: red; }', + cssFile: 'devup-ui.css', + map: undefined, + updatedBaseStyle: options.updatedBaseStyle, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + const ret = await transform.mock.calls[1][1]({ + code: `import { Box } from '@devup-ui/react' +const App = () => `, + resourcePath: 'src/App.tsx', + }) + expect(ret).toEqual({ + code: '
', + map: undefined, + }) + + if (options.updatedBaseStyle) { + expect(writeFile).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), + 'utf-8', + ) + } + expect(writeFile).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), + 'utf-8', + ) + + const ret1 = await transform.mock.calls[1][1]({ + code: `import { Box } from '@devup-ui/react' +const App = () => `, + resourcePath: 'node_modules/@devup-ui/react/index.tsx', + }) + expect(ret1).toEqual({ + code: `
`, + map: undefined, + }) + }) + it.each( + createTestMatrix({ + watch: [true, false], + existsDevupFile: [true, false], + existsDistDir: [true, false], + existsSheetFile: [true, false], + existsClassMapFile: [true, false], + existsFileMapFile: [true, false], + existsCssDir: [true, false], + getDefaultTheme: ['theme', ''], + singleCss: [true, false], + }), + )('should write data files', async (options) => { + vi.mocked(writeFile).mockResolvedValueOnce(undefined) + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(getDefaultTheme).mockReturnValue(options.getDefaultTheme) + vi.mocked(existsSync).mockImplementation((path) => { + if (path === 'devup.json') return options.existsDevupFile + if (path === 'df') return options.existsDistDir + if (path === resolve('df', 'devup-ui')) return options.existsCssDir + if (path === join('df', 'sheet.json')) return options.existsSheetFile + if (path === join('df', 'classMap.json')) + return options.existsClassMapFile + if (path === join('df', 'fileMap.json')) return options.existsFileMapFile + return false + }) + const plugin = DevupUI({ singleCss: options.singleCss }) + await (plugin as any).setup({ + transform: vi.fn(), + renderChunk: vi.fn(), + generateBundle: vi.fn(), + closeBundle: vi.fn(), + resolve: vi.fn(), + load: vi.fn(), + modifyRsbuildConfig: vi.fn(), + watchChange: vi.fn(), + resolveId: vi.fn(), + } as any) + if (options.existsDevupFile) { + expect(readFile).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith({}) + expect(getThemeInterface).toHaveBeenCalledWith( + '@devup-ui/react', + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + expect(writeFile).toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + 'interface code', + 'utf-8', + ) + } else { + expect(registerTheme).toHaveBeenCalledWith({}) + } + + const modifyRsbuildConfig = vi.fn() + await (plugin as any).setup({ + transform: vi.fn(), + renderChunk: vi.fn(), + generateBundle: vi.fn(), + closeBundle: vi.fn(), + resolve: vi.fn(), + modifyRsbuildConfig, + load: vi.fn(), + watchChange: vi.fn(), + resolveId: vi.fn(), + } as any) + if (options.getDefaultTheme) { + expect(modifyRsbuildConfig).toHaveBeenCalledWith(expect.any(Function)) + const config = { + source: { + define: {}, + }, + } + modifyRsbuildConfig.mock.calls[0][0](config) + expect(config).toEqual({ + source: { + define: { + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( + options.getDefaultTheme, + ), + }, + }, + }) + } else { + expect(modifyRsbuildConfig).toHaveBeenCalledWith(expect.any(Function)) + const config = { + source: { + define: {}, + }, + } + modifyRsbuildConfig.mock.calls[0][0](config) + expect(config).toEqual({ + source: { + define: {}, + }, + }) + } + }) + + it('should call setPrefix when prefix option is provided', async () => { + const plugin = DevupUI({ prefix: 'my-prefix' }) + await plugin.setup({ + transform: vi.fn(), + modifyRsbuildConfig: vi.fn(), + } as any) + expect(setPrefix).toHaveBeenCalledWith('my-prefix') + }) +}) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index be529151..5712d537 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,176 +1,182 @@ -import { existsSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { basename, join, resolve } from 'node:path' - -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - registerTheme, - setDebug, -} from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' - -export interface DevupUIRsbuildPluginOptions { - package: string - cssDir: string - devupFile: string - distDir: string - extractCss: boolean - debug: boolean - include: string[] - singleCss: boolean -} - -let globalCss = '' - -async function writeDataFiles( - options: Omit< - DevupUIRsbuildPluginOptions, - 'extractCss' | 'debug' | 'include' - >, -) { - try { - const content = existsSync(options.devupFile) - ? await readFile(options.devupFile, 'utf-8') - : undefined - - if (content) { - registerTheme(JSON.parse(content)?.['theme'] ?? {}) - const interfaceCode = getThemeInterface( - options.package, - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } else { - registerTheme({}) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - await Promise.all([ - !existsSync(options.cssDir) - ? mkdir(options.cssDir, { recursive: true }) - : Promise.resolve(), - !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) - : Promise.resolve(), - ]) -} - -export const DevupUI = ({ - include = [], - package: libPackage = '@devup-ui/react', - extractCss = true, - distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), - devupFile = 'devup.json', - debug = false, - singleCss = false, -}: Partial = {}): RsbuildPlugin => ({ - name: 'devup-ui-rsbuild-plugin', - async setup(api) { - setDebug(debug) - - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - if (!extractCss) return - - api.transform( - { - test: cssDir, - }, - () => globalCss, - ) - - api.modifyRsbuildConfig((config) => { - const theme = getDefaultTheme() - if (theme) { - config.source ??= {} - config.source.define = { - 'process.env.DEVUP_UI_DEFAULT_THEME': - JSON.stringify(getDefaultTheme()), - ...config.source.define, - } - } - return config - }) - - api.transform( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - async ({ code, resourcePath }) => { - if ( - new RegExp( - `node_modules(?!.*(${['@devup-ui', ...include] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))`, - ).test(resourcePath) - ) - return code - const { - code: retCode, - css = '', - map, - cssFile, - updatedBaseStyle, - } = codeExtract( - resourcePath, - code, - libPackage, - cssDir, - singleCss, - false, - true, - ) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile( - join(cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ), - ) - } - - if (cssFile) { - if (globalCss.length < css.length) globalCss = css - promises.push( - writeFile( - join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, - 'utf-8', - ), - ) - } - await Promise.all(promises) - return { - code: retCode, - map, - } - }, - ) - }, -}) +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' + +import { + codeExtract, + getCss, + getDefaultTheme, + getThemeInterface, + registerTheme, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import type { RsbuildPlugin } from '@rsbuild/core' + +export interface DevupUIRsbuildPluginOptions { + package: string + cssDir: string + devupFile: string + distDir: string + extractCss: boolean + debug: boolean + include: string[] + singleCss: boolean + prefix?: string +} + +let globalCss = '' + +async function writeDataFiles( + options: Omit< + DevupUIRsbuildPluginOptions, + 'extractCss' | 'debug' | 'include' + >, +) { + try { + const content = existsSync(options.devupFile) + ? await readFile(options.devupFile, 'utf-8') + : undefined + + if (content) { + registerTheme(JSON.parse(content)?.['theme'] ?? {}) + const interfaceCode = getThemeInterface( + options.package, + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + + if (interfaceCode) { + await writeFile( + join(options.distDir, 'theme.d.ts'), + interfaceCode, + 'utf-8', + ) + } + } else { + registerTheme({}) + } + } catch (error) { + console.error(error) + registerTheme({}) + } + await Promise.all([ + !existsSync(options.cssDir) + ? mkdir(options.cssDir, { recursive: true }) + : Promise.resolve(), + !options.singleCss + ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + : Promise.resolve(), + ]) +} + +export const DevupUI = ({ + include = [], + package: libPackage = '@devup-ui/react', + extractCss = true, + distDir = 'df', + cssDir = resolve(distDir, 'devup-ui'), + devupFile = 'devup.json', + debug = false, + singleCss = false, + prefix, +}: Partial = {}): RsbuildPlugin => ({ + name: 'devup-ui-rsbuild-plugin', + async setup(api) { + setDebug(debug) + if (prefix) { + setPrefix(prefix) + } + + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + if (!extractCss) return + + api.transform( + { + test: cssDir, + }, + () => globalCss, + ) + + api.modifyRsbuildConfig((config) => { + const theme = getDefaultTheme() + if (theme) { + config.source ??= {} + config.source.define = { + 'process.env.DEVUP_UI_DEFAULT_THEME': + JSON.stringify(getDefaultTheme()), + ...config.source.define, + } + } + return config + }) + + api.transform( + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + }, + async ({ code, resourcePath }) => { + if ( + new RegExp( + `node_modules(?!.*(${['@devup-ui', ...include] + .join('|') + .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))`, + ).test(resourcePath) + ) + return code + const { + code: retCode, + css = '', + map, + cssFile, + updatedBaseStyle, + } = codeExtract( + resourcePath, + code, + libPackage, + cssDir, + singleCss, + false, + true, + ) + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style + promises.push( + writeFile( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), + ) + } + + if (cssFile) { + if (globalCss.length < css.length) globalCss = css + promises.push( + writeFile( + join(cssDir, basename(cssFile)), + `/* ${resourcePath} ${Date.now()} */`, + 'utf-8', + ), + ) + } + await Promise.all(promises) + return { + code: retCode, + map, + } + }, + ) + }, +}) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 73ab5505..8e04a757 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1,392 +1,398 @@ -import { existsSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - registerTheme, - setDebug, -} from '@devup-ui/wasm' -import { describe } from 'vitest' - -import { DevupUI } from '../plugin' - -vi.mock('@devup-ui/wasm') -vi.mock('node:fs') -vi.mock('node:fs/promises') -vi.mock('node:path', async (original: any) => { - const origin = await original() - return { - ...origin, - relative: vi.fn(origin.relative), - } -}) - -const _filename = fileURLToPath(import.meta.url) -const _dirname = resolve(dirname(_filename), '..') -beforeEach(() => { - vi.resetAllMocks() -}) - -describe('devupUIVitePlugin', () => { - console.error = vi.fn() - it('should apply default options', () => { - const plugin = DevupUI({}) - expect(plugin).toEqual({ - name: 'devup-ui', - config: expect.any(Function), - load: expect.any(Function), - watchChange: expect.any(Function), - enforce: 'pre', - transform: expect.any(Function), - apply: expect.any(Function), - generateBundle: expect.any(Function), - configResolved: expect.any(Function), - resolveId: expect.any(Function), - }) - expect((plugin as any).apply()).toBe(true) - }) - it.each( - globalThis.createTestMatrix({ - debug: [true, false], - extractCss: [true, false], - }), - )('should apply options', async (options) => { - const plugin = DevupUI(options) - expect(setDebug).toHaveBeenCalledWith(options.debug) - if (options.extractCss) { - expect( - (plugin as any) - .config() - .build.rollupOptions.output.manualChunks('devup-ui.css', 'code'), - ).toEqual('devup-ui.css') - - expect( - (plugin as any) - .config() - .build.rollupOptions.output.manualChunks('other.css', 'code'), - ).toEqual(undefined) - } else { - expect((plugin as any).config().build).toBeUndefined() - } - }) - - it.each( - createTestMatrix({ - watch: [true, false], - existsDevupFile: [true, false], - existsDistDir: [true, false], - existsSheetFile: [true, false], - existsClassMapFile: [true, false], - existsFileMapFile: [true, false], - existsCssDir: [true, false], - getDefaultTheme: ['theme', ''], - singleCss: [true, false], - }), - )('should write data files', async (options) => { - vi.mocked(writeFile).mockResolvedValueOnce(undefined) - vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(getDefaultTheme).mockReturnValue(options.getDefaultTheme) - vi.mocked(existsSync).mockImplementation((path) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir - if (path === resolve('df', 'devup-ui')) return options.existsCssDir - if (path === join('df', 'sheet.json')) return options.existsSheetFile - if (path === join('df', 'classMap.json')) - return options.existsClassMapFile - if (path === join('df', 'fileMap.json')) return options.existsFileMapFile - return false - }) - const plugin = DevupUI({ singleCss: options.singleCss }) - await (plugin as any).configResolved() - if (options.existsDevupFile) { - expect(readFile).toHaveBeenCalledWith('devup.json', 'utf-8') - expect(registerTheme).toHaveBeenCalledWith({}) - expect(getThemeInterface).toHaveBeenCalledWith( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - expect(writeFile).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface code', - 'utf-8', - ) - } else { - expect(registerTheme).toHaveBeenCalledWith({}) - } - - const config = (plugin as any).config() - if (options.getDefaultTheme) { - expect(config.define).toEqual({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( - options.getDefaultTheme, - ), - }) - } else { - expect(config.define).toEqual({}) - } - }) - - it('should reset data files when load error', async () => { - vi.mocked(writeFile).mockResolvedValueOnce(undefined) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(readFile).mockImplementation(() => { - throw new Error('error') - }) - const plugin = DevupUI({}) - await (plugin as any).configResolved() - expect(registerTheme).toHaveBeenCalledWith({}) - expect(writeFile).toHaveBeenCalledWith( - join('df', '.gitignore'), - '*', - 'utf-8', - ) - }) - - it('should watch change', async () => { - vi.mocked(writeFile).mockResolvedValueOnce(undefined) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(readFile).mockResolvedValueOnce( - JSON.stringify({ theme: 'theme' }), - ) - const plugin = DevupUI({}) - await (plugin as any).watchChange('devup.json') - expect(writeFile).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface code', - 'utf-8', - ) - - await (plugin as any).watchChange('wrong') - }) - - it('should print error when watch change error', async () => { - vi.mocked(writeFile).mockResolvedValueOnce(undefined) - vi.mocked(getThemeInterface).mockReturnValue('interface code') - vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false) - vi.mocked(mkdir).mockImplementation(() => { - throw new Error('error') - }) - const plugin = DevupUI({}) - await (plugin as any).watchChange('devup.json') - expect(console.error).toHaveBeenCalledWith(expect.any(Error)) - }) - - it('should load', () => { - vi.mocked(getCss).mockReturnValue('css code') - const plugin = DevupUI({}) - expect((plugin as any).load('devup-ui.css')).toEqual(expect.any(String)) - expect((plugin as any).load('devup-ui-10.css')).toEqual(expect.any(String)) - }) - - it.each( - createTestMatrix({ - extractCss: [true, false], - updatedBaseStyle: [true, false], - }), - )('should transform', async (options) => { - vi.mocked(getCss).mockReturnValue('css code') - vi.mocked(codeExtract).mockReturnValue({ - css: 'css code', - code: 'code', - cssFile: 'devup-ui.css', - map: undefined, - updatedBaseStyle: options.updatedBaseStyle, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - - const plugin = DevupUI(options) - - expect(await (plugin as any).transform('code', 'devup-ui.wrong')).toEqual( - undefined, - ) - expect(await (plugin as any).transform('code', 'devup-ui.tsx')).toEqual( - options.extractCss ? { code: 'code' } : undefined, - ) - - if (options.extractCss) { - expect( - await (plugin as any).transform('code', 'node_modules/test/index.tsx'), - ).toEqual(undefined) - expect( - await (plugin as any).transform( - 'code', - 'node_modules/@devup-ui/hello/index.tsx', - ), - ).toEqual({ code: 'code' }) - - vi.mocked(codeExtract).mockReturnValue({ - css: 'css code test next', - code: 'code', - cssFile: 'devup-ui.css', - map: undefined, - updatedBaseStyle: options.updatedBaseStyle, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - expect(writeFile).toHaveBeenCalledWith( - join(resolve('df', 'devup-ui'), 'devup-ui.css'), - expect.stringMatching( - /\/\* node_modules[/\\]@devup-ui[/\\]hello[/\\]index\.tsx \d+ \*\//, - ), - 'utf-8', - ) - expect( - await (plugin as any).transform( - 'code', - 'node_modules/@devup-ui/hello/index.tsx', - ), - ).toEqual({ code: 'code' }) - } - expect(await (plugin as any).load('devup-ui.css')).toEqual( - expect.any(String), - ) - - vi.mocked(codeExtract).mockReturnValue({ - css: 'long css code', - code: 'long code', - cssFile: 'devup-ui.css', - map: undefined, - updatedBaseStyle: options.updatedBaseStyle, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - expect(await (plugin as any).transform('code', 'devup-ui.tsx')).toEqual( - options.extractCss ? { code: 'long code' } : undefined, - ) - }) - - it.each( - createTestMatrix({ - extractCss: [true, false], - }), - )('should generateBundle', async (options) => { - vi.mocked(getCss).mockReturnValue('css code test') - const plugin = DevupUI({ extractCss: options.extractCss, singleCss: true }) - const bundle = { - 'devup-ui.css': { source: 'css code', name: 'devup-ui.css' }, - } as any - ;(plugin as any).load('devup-ui.css') - await (plugin as any).generateBundle({}, bundle) - if (options.extractCss) { - expect(bundle['devup-ui.css'].source).toEqual('css code test') - } else { - expect(bundle['devup-ui.css'].source).toEqual('css code') - } - }) - it('should resolveId', () => { - vi.mocked(getCss).mockReturnValue('css code') - { - const plugin = DevupUI({}) - expect( - (plugin as any).resolveId('devup-ui.css', 'df/devup-ui/devup-ui.css'), - ).toEqual(expect.any(String)) - - expect( - (plugin as any).resolveId('other.css', 'df/devup-ui/devup-ui.css'), - ).toEqual(undefined) - } - - { - const plugin = DevupUI({ - cssDir: '', - }) - expect((plugin as any).resolveId('devup-ui.css')).toEqual( - expect.any(String), - ) - } - }) - it('should resolve id with cssMap', () => { - vi.mocked(getCss).mockReturnValue('css code') - const plugin = DevupUI({}) - expect((plugin as any).load('devup-ui.css')).toEqual(expect.any(String)) - expect((plugin as any).load('other.css')).toEqual(undefined) - - expect( - (plugin as any).resolveId('devup-ui.css', 'df/devup-ui/devup-ui.css'), - ).toEqual(expect.any(String)) - }) - it('should not write interface code when no theme', async () => { - vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) - vi.mocked(getThemeInterface).mockReturnValue('') - vi.mocked(existsSync).mockReturnValue(true) - const plugin = DevupUI({}) - await (plugin as any).configResolved() - expect(writeFile).not.toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - expect.any(String), - 'utf-8', - ) - }) - - it('sholud add relative path to css file', async () => { - vi.mocked(getCss).mockReturnValue('css code') - vi.mocked(codeExtract).mockReturnValue({ - css: 'css code', - code: 'code', - cssFile: 'devup-ui.css', - map: undefined, - updatedBaseStyle: false, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - const plugin = DevupUI({}) - vi.mocked(relative).mockReturnValue('./df/devup-ui/devup-ui.css') - await (plugin as any).transform('code', 'foo.tsx') - - expect(codeExtract).toHaveBeenCalledWith( - 'foo.tsx', - 'code', - '@devup-ui/react', - './df/devup-ui/devup-ui.css', - false, - true, - false, - ) - - vi.mocked(relative).mockReturnValue('df/devup-ui/devup-ui.css') - await (plugin as any).transform('code', 'foo.tsx') - expect(codeExtract).toHaveBeenCalledWith( - 'foo.tsx', - 'code', - '@devup-ui/react', - './df/devup-ui/devup-ui.css', - false, - true, - false, - ) - }) - - it('should not create css file when cssFile is empty', async () => { - vi.mocked(getCss).mockReturnValue('css code') - vi.mocked(codeExtract).mockReturnValue({ - css: 'css code', - code: 'code', - cssFile: '', - map: undefined, - updatedBaseStyle: false, - free: vi.fn(), - [Symbol.dispose]: vi.fn(), - }) - const plugin = DevupUI({}) - await (plugin as any).transform('code', 'foo.tsx') - expect(writeFile).not.toHaveBeenCalled() - }) - - it('should not generate bundle when css file is not found', async () => { - const plugin = DevupUI({}) - const bundle = {} as any - await (plugin as any).generateBundle({}, bundle) - expect(bundle).toEqual({}) - }) -}) +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + codeExtract, + getCss, + getDefaultTheme, + getThemeInterface, + registerTheme, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import { describe } from 'vitest' + +import { DevupUI } from '../plugin' + +vi.mock('@devup-ui/wasm') +vi.mock('node:fs') +vi.mock('node:fs/promises') +vi.mock('node:path', async (original: any) => { + const origin = await original() + return { + ...origin, + relative: vi.fn(origin.relative), + } +}) + +const _filename = fileURLToPath(import.meta.url) +const _dirname = resolve(dirname(_filename), '..') +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('devupUIVitePlugin', () => { + console.error = vi.fn() + it('should apply default options', () => { + const plugin = DevupUI({}) + expect(plugin).toEqual({ + name: 'devup-ui', + config: expect.any(Function), + load: expect.any(Function), + watchChange: expect.any(Function), + enforce: 'pre', + transform: expect.any(Function), + apply: expect.any(Function), + generateBundle: expect.any(Function), + configResolved: expect.any(Function), + resolveId: expect.any(Function), + }) + expect((plugin as any).apply()).toBe(true) + }) + it.each( + globalThis.createTestMatrix({ + debug: [true, false], + extractCss: [true, false], + }), + )('should apply options', async (options) => { + const plugin = DevupUI(options) + expect(setDebug).toHaveBeenCalledWith(options.debug) + if (options.extractCss) { + expect( + (plugin as any) + .config() + .build.rollupOptions.output.manualChunks('devup-ui.css', 'code'), + ).toEqual('devup-ui.css') + + expect( + (plugin as any) + .config() + .build.rollupOptions.output.manualChunks('other.css', 'code'), + ).toEqual(undefined) + } else { + expect((plugin as any).config().build).toBeUndefined() + } + }) + + it.each( + createTestMatrix({ + watch: [true, false], + existsDevupFile: [true, false], + existsDistDir: [true, false], + existsSheetFile: [true, false], + existsClassMapFile: [true, false], + existsFileMapFile: [true, false], + existsCssDir: [true, false], + getDefaultTheme: ['theme', ''], + singleCss: [true, false], + }), + )('should write data files', async (options) => { + vi.mocked(writeFile).mockResolvedValueOnce(undefined) + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(getDefaultTheme).mockReturnValue(options.getDefaultTheme) + vi.mocked(existsSync).mockImplementation((path) => { + if (path === 'devup.json') return options.existsDevupFile + if (path === 'df') return options.existsDistDir + if (path === resolve('df', 'devup-ui')) return options.existsCssDir + if (path === join('df', 'sheet.json')) return options.existsSheetFile + if (path === join('df', 'classMap.json')) + return options.existsClassMapFile + if (path === join('df', 'fileMap.json')) return options.existsFileMapFile + return false + }) + const plugin = DevupUI({ singleCss: options.singleCss }) + await (plugin as any).configResolved() + if (options.existsDevupFile) { + expect(readFile).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith({}) + expect(getThemeInterface).toHaveBeenCalledWith( + '@devup-ui/react', + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + expect(writeFile).toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + 'interface code', + 'utf-8', + ) + } else { + expect(registerTheme).toHaveBeenCalledWith({}) + } + + const config = (plugin as any).config() + if (options.getDefaultTheme) { + expect(config.define).toEqual({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify( + options.getDefaultTheme, + ), + }) + } else { + expect(config.define).toEqual({}) + } + }) + + it('should reset data files when load error', async () => { + vi.mocked(writeFile).mockResolvedValueOnce(undefined) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFile).mockImplementation(() => { + throw new Error('error') + }) + const plugin = DevupUI({}) + await (plugin as any).configResolved() + expect(registerTheme).toHaveBeenCalledWith({}) + expect(writeFile).toHaveBeenCalledWith( + join('df', '.gitignore'), + '*', + 'utf-8', + ) + }) + + it('should watch change', async () => { + vi.mocked(writeFile).mockResolvedValueOnce(undefined) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValueOnce( + JSON.stringify({ theme: 'theme' }), + ) + const plugin = DevupUI({}) + await (plugin as any).watchChange('devup.json') + expect(writeFile).toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + 'interface code', + 'utf-8', + ) + + await (plugin as any).watchChange('wrong') + }) + + it('should print error when watch change error', async () => { + vi.mocked(writeFile).mockResolvedValueOnce(undefined) + vi.mocked(getThemeInterface).mockReturnValue('interface code') + vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false) + vi.mocked(mkdir).mockImplementation(() => { + throw new Error('error') + }) + const plugin = DevupUI({}) + await (plugin as any).watchChange('devup.json') + expect(console.error).toHaveBeenCalledWith(expect.any(Error)) + }) + + it('should load', () => { + vi.mocked(getCss).mockReturnValue('css code') + const plugin = DevupUI({}) + expect((plugin as any).load('devup-ui.css')).toEqual(expect.any(String)) + expect((plugin as any).load('devup-ui-10.css')).toEqual(expect.any(String)) + }) + + it.each( + createTestMatrix({ + extractCss: [true, false], + updatedBaseStyle: [true, false], + }), + )('should transform', async (options) => { + vi.mocked(getCss).mockReturnValue('css code') + vi.mocked(codeExtract).mockReturnValue({ + css: 'css code', + code: 'code', + cssFile: 'devup-ui.css', + map: undefined, + updatedBaseStyle: options.updatedBaseStyle, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + + const plugin = DevupUI(options) + + expect(await (plugin as any).transform('code', 'devup-ui.wrong')).toEqual( + undefined, + ) + expect(await (plugin as any).transform('code', 'devup-ui.tsx')).toEqual( + options.extractCss ? { code: 'code' } : undefined, + ) + + if (options.extractCss) { + expect( + await (plugin as any).transform('code', 'node_modules/test/index.tsx'), + ).toEqual(undefined) + expect( + await (plugin as any).transform( + 'code', + 'node_modules/@devup-ui/hello/index.tsx', + ), + ).toEqual({ code: 'code' }) + + vi.mocked(codeExtract).mockReturnValue({ + css: 'css code test next', + code: 'code', + cssFile: 'devup-ui.css', + map: undefined, + updatedBaseStyle: options.updatedBaseStyle, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + expect(writeFile).toHaveBeenCalledWith( + join(resolve('df', 'devup-ui'), 'devup-ui.css'), + expect.stringMatching( + /\/\* node_modules[/\\]@devup-ui[/\\]hello[/\\]index\.tsx \d+ \*\//, + ), + 'utf-8', + ) + expect( + await (plugin as any).transform( + 'code', + 'node_modules/@devup-ui/hello/index.tsx', + ), + ).toEqual({ code: 'code' }) + } + expect(await (plugin as any).load('devup-ui.css')).toEqual( + expect.any(String), + ) + + vi.mocked(codeExtract).mockReturnValue({ + css: 'long css code', + code: 'long code', + cssFile: 'devup-ui.css', + map: undefined, + updatedBaseStyle: options.updatedBaseStyle, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + expect(await (plugin as any).transform('code', 'devup-ui.tsx')).toEqual( + options.extractCss ? { code: 'long code' } : undefined, + ) + }) + + it.each( + createTestMatrix({ + extractCss: [true, false], + }), + )('should generateBundle', async (options) => { + vi.mocked(getCss).mockReturnValue('css code test') + const plugin = DevupUI({ extractCss: options.extractCss, singleCss: true }) + const bundle = { + 'devup-ui.css': { source: 'css code', name: 'devup-ui.css' }, + } as any + ;(plugin as any).load('devup-ui.css') + await (plugin as any).generateBundle({}, bundle) + if (options.extractCss) { + expect(bundle['devup-ui.css'].source).toEqual('css code test') + } else { + expect(bundle['devup-ui.css'].source).toEqual('css code') + } + }) + it('should resolveId', () => { + vi.mocked(getCss).mockReturnValue('css code') + { + const plugin = DevupUI({}) + expect( + (plugin as any).resolveId('devup-ui.css', 'df/devup-ui/devup-ui.css'), + ).toEqual(expect.any(String)) + + expect( + (plugin as any).resolveId('other.css', 'df/devup-ui/devup-ui.css'), + ).toEqual(undefined) + } + + { + const plugin = DevupUI({ + cssDir: '', + }) + expect((plugin as any).resolveId('devup-ui.css')).toEqual( + expect.any(String), + ) + } + }) + it('should resolve id with cssMap', () => { + vi.mocked(getCss).mockReturnValue('css code') + const plugin = DevupUI({}) + expect((plugin as any).load('devup-ui.css')).toEqual(expect.any(String)) + expect((plugin as any).load('other.css')).toEqual(undefined) + + expect( + (plugin as any).resolveId('devup-ui.css', 'df/devup-ui/devup-ui.css'), + ).toEqual(expect.any(String)) + }) + it('should not write interface code when no theme', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})) + vi.mocked(getThemeInterface).mockReturnValue('') + vi.mocked(existsSync).mockReturnValue(true) + const plugin = DevupUI({}) + await (plugin as any).configResolved() + expect(writeFile).not.toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + expect.any(String), + 'utf-8', + ) + }) + + it('sholud add relative path to css file', async () => { + vi.mocked(getCss).mockReturnValue('css code') + vi.mocked(codeExtract).mockReturnValue({ + css: 'css code', + code: 'code', + cssFile: 'devup-ui.css', + map: undefined, + updatedBaseStyle: false, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + const plugin = DevupUI({}) + vi.mocked(relative).mockReturnValue('./df/devup-ui/devup-ui.css') + await (plugin as any).transform('code', 'foo.tsx') + + expect(codeExtract).toHaveBeenCalledWith( + 'foo.tsx', + 'code', + '@devup-ui/react', + './df/devup-ui/devup-ui.css', + false, + true, + false, + ) + + vi.mocked(relative).mockReturnValue('df/devup-ui/devup-ui.css') + await (plugin as any).transform('code', 'foo.tsx') + expect(codeExtract).toHaveBeenCalledWith( + 'foo.tsx', + 'code', + '@devup-ui/react', + './df/devup-ui/devup-ui.css', + false, + true, + false, + ) + }) + + it('should not create css file when cssFile is empty', async () => { + vi.mocked(getCss).mockReturnValue('css code') + vi.mocked(codeExtract).mockReturnValue({ + css: 'css code', + code: 'code', + cssFile: '', + map: undefined, + updatedBaseStyle: false, + free: vi.fn(), + [Symbol.dispose]: vi.fn(), + }) + const plugin = DevupUI({}) + await (plugin as any).transform('code', 'foo.tsx') + expect(writeFile).not.toHaveBeenCalled() + }) + + it('should not generate bundle when css file is not found', async () => { + const plugin = DevupUI({}) + const bundle = {} as any + await (plugin as any).generateBundle({}, bundle) + expect(bundle).toEqual({}) + }) + + it('should call setPrefix when prefix option is provided', () => { + DevupUI({ prefix: 'my-prefix' }) + expect(setPrefix).toHaveBeenCalledWith('my-prefix') + }) +}) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index a84f836f..4c51a559 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -1,242 +1,248 @@ -import { existsSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative, resolve } from 'node:path' - -import { - codeExtract, - getCss, - getDefaultTheme, - getThemeInterface, - registerTheme, - setDebug, -} from '@devup-ui/wasm' -import { type PluginOption, type UserConfig } from 'vite' - -export interface DevupUIPluginOptions { - package: string - cssDir: string - devupFile: string - distDir: string - extractCss: boolean - debug: boolean - include: string[] - singleCss: boolean -} - -function getFileNumByFilename(filename: string) { - if (filename.endsWith('devup-ui.css')) return null - return parseInt(filename.split('devup-ui-')[1].split('.')[0]) -} - -async function writeDataFiles( - options: Omit, -) { - try { - const content = existsSync(options.devupFile) - ? await readFile(options.devupFile, 'utf-8') - : undefined - - if (content) { - registerTheme(JSON.parse(content)?.['theme'] ?? {}) - const interfaceCode = getThemeInterface( - options.package, - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } else { - registerTheme({}) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - await Promise.all([ - !existsSync(options.cssDir) - ? mkdir(options.cssDir, { recursive: true }) - : Promise.resolve(), - !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) - : Promise.resolve(), - ]) -} - -export function DevupUI({ - package: libPackage = '@devup-ui/react', - devupFile = 'devup.json', - distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), - extractCss = true, - debug = false, - include = [], - singleCss = false, -}: Partial = {}): PluginOption { - setDebug(debug) - const cssMap = new Map() - return { - name: 'devup-ui', - async configResolved() { - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - }, - config() { - const theme = getDefaultTheme() - const define: Record = {} - if (theme) { - define['process.env.DEVUP_UI_DEFAULT_THEME'] = JSON.stringify(theme) - } - const ret: Omit = { - server: { - watch: { - ignored: [`!${devupFile}`], - }, - }, - define, - optimizeDeps: { - exclude: [...include, '@devup-ui/components'], - }, - ssr: { - noExternal: [...include, /@devup-ui/], - }, - } - if (extractCss) { - ret['build'] = { - rollupOptions: { - output: { - manualChunks(id) { - // merge devup css files - const fileName = basename(id).split('?')[0] - if (/devup-ui(-\d+)?\.css$/.test(fileName)) { - return fileName - } - }, - }, - }, - } - } - return ret - }, - apply() { - return true - }, - async watchChange(id) { - if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { - try { - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - } catch (error) { - console.error(error) - } - } - }, - resolveId(id, importer) { - const fileName = basename(id).split('?')[0] - if ( - /devup-ui(-\d+)?\.css$/.test(fileName) && - resolve(importer ? join(dirname(importer), id) : id) === - resolve(join(cssDir, fileName)) - ) { - return join( - cssDir, - `${fileName}?t=${ - Date.now().toString() + - (cssMap.get(getFileNumByFilename(fileName))?.length ?? 0) - }`, - ) - } - }, - load(id) { - const fileName = basename(id).split('?')[0] - if (/devup-ui(-\d+)?\.css$/.test(fileName)) { - const fileNum = getFileNumByFilename(fileName) - const css = getCss(fileNum, false) - cssMap.set(fileNum, css) - return css - } - }, - enforce: 'pre', - async transform(code, id) { - if (!extractCss) return - - const fileName = id.split('?')[0] - if (!/\.(tsx|ts|js|mjs|jsx)$/i.test(fileName)) return - if ( - new RegExp( - `node_modules(?!.*(${['@devup-ui', ...include] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))`, - ).test(fileName) - ) { - return - } - - let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') - if (!rel.startsWith('./')) rel = `./${rel}` - - const { - code: retCode, - css = '', - map, - cssFile, - updatedBaseStyle, - // import main css in code - } = codeExtract(fileName, code, libPackage, rel, singleCss, true, false) - const promises: Promise[] = [] - - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), - ) - } - - if (cssFile) { - const fileNum = getFileNumByFilename(cssFile!) - const prevCss = cssMap.get(fileNum) - if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) - promises.push( - writeFile( - join(cssDir, basename(cssFile!)), - `/* ${id} ${Date.now()} */`, - 'utf-8', - ), - ) - } - await Promise.all(promises) - return { - code: retCode, - map, - } - }, - async generateBundle(_options, bundle) { - if (!extractCss) return - - const cssFile = Object.keys(bundle).find( - (file) => bundle[file].name === 'devup-ui.css', - ) - if (cssFile && 'source' in bundle[cssFile]) { - bundle[cssFile].source = cssMap.get(null)! - } - }, - } -} +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, join, relative, resolve } from 'node:path' + +import { + codeExtract, + getCss, + getDefaultTheme, + getThemeInterface, + registerTheme, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import { type PluginOption, type UserConfig } from 'vite' + +export interface DevupUIPluginOptions { + package: string + cssDir: string + devupFile: string + distDir: string + extractCss: boolean + debug: boolean + include: string[] + singleCss: boolean + prefix?: string +} + +function getFileNumByFilename(filename: string) { + if (filename.endsWith('devup-ui.css')) return null + return parseInt(filename.split('devup-ui-')[1].split('.')[0]) +} + +async function writeDataFiles( + options: Omit, +) { + try { + const content = existsSync(options.devupFile) + ? await readFile(options.devupFile, 'utf-8') + : undefined + + if (content) { + registerTheme(JSON.parse(content)?.['theme'] ?? {}) + const interfaceCode = getThemeInterface( + options.package, + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + + if (interfaceCode) { + await writeFile( + join(options.distDir, 'theme.d.ts'), + interfaceCode, + 'utf-8', + ) + } + } else { + registerTheme({}) + } + } catch (error) { + console.error(error) + registerTheme({}) + } + await Promise.all([ + !existsSync(options.cssDir) + ? mkdir(options.cssDir, { recursive: true }) + : Promise.resolve(), + !options.singleCss + ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + : Promise.resolve(), + ]) +} + +export function DevupUI({ + package: libPackage = '@devup-ui/react', + devupFile = 'devup.json', + distDir = 'df', + cssDir = resolve(distDir, 'devup-ui'), + extractCss = true, + debug = false, + include = [], + singleCss = false, + prefix, +}: Partial = {}): PluginOption { + setDebug(debug) + if (prefix) { + setPrefix(prefix) + } + const cssMap = new Map() + return { + name: 'devup-ui', + async configResolved() { + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + }, + config() { + const theme = getDefaultTheme() + const define: Record = {} + if (theme) { + define['process.env.DEVUP_UI_DEFAULT_THEME'] = JSON.stringify(theme) + } + const ret: Omit = { + server: { + watch: { + ignored: [`!${devupFile}`], + }, + }, + define, + optimizeDeps: { + exclude: [...include, '@devup-ui/components'], + }, + ssr: { + noExternal: [...include, /@devup-ui/], + }, + } + if (extractCss) { + ret['build'] = { + rollupOptions: { + output: { + manualChunks(id) { + // merge devup css files + const fileName = basename(id).split('?')[0] + if (/devup-ui(-\d+)?\.css$/.test(fileName)) { + return fileName + } + }, + }, + }, + } + } + return ret + }, + apply() { + return true + }, + async watchChange(id) { + if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { + try { + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + } catch (error) { + console.error(error) + } + } + }, + resolveId(id, importer) { + const fileName = basename(id).split('?')[0] + if ( + /devup-ui(-\d+)?\.css$/.test(fileName) && + resolve(importer ? join(dirname(importer), id) : id) === + resolve(join(cssDir, fileName)) + ) { + return join( + cssDir, + `${fileName}?t=${ + Date.now().toString() + + (cssMap.get(getFileNumByFilename(fileName))?.length ?? 0) + }`, + ) + } + }, + load(id) { + const fileName = basename(id).split('?')[0] + if (/devup-ui(-\d+)?\.css$/.test(fileName)) { + const fileNum = getFileNumByFilename(fileName) + const css = getCss(fileNum, false) + cssMap.set(fileNum, css) + return css + } + }, + enforce: 'pre', + async transform(code, id) { + if (!extractCss) return + + const fileName = id.split('?')[0] + if (!/\.(tsx|ts|js|mjs|jsx)$/i.test(fileName)) return + if ( + new RegExp( + `node_modules(?!.*(${['@devup-ui', ...include] + .join('|') + .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))`, + ).test(fileName) + ) { + return + } + + let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') + if (!rel.startsWith('./')) rel = `./${rel}` + + const { + code: retCode, + css = '', + map, + cssFile, + updatedBaseStyle, + // import main css in code + } = codeExtract(fileName, code, libPackage, rel, singleCss, true, false) + const promises: Promise[] = [] + + if (updatedBaseStyle) { + // update base style + promises.push( + writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + ) + } + + if (cssFile) { + const fileNum = getFileNumByFilename(cssFile!) + const prevCss = cssMap.get(fileNum) + if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) + promises.push( + writeFile( + join(cssDir, basename(cssFile!)), + `/* ${id} ${Date.now()} */`, + 'utf-8', + ), + ) + } + await Promise.all(promises) + return { + code: retCode, + map, + } + }, + async generateBundle(_options, bundle) { + if (!extractCss) return + + const cssFile = Object.keys(bundle).find( + (file) => bundle[file].name === 'devup-ui.css', + ) + if (cssFile && 'source' in bundle[cssFile]) { + bundle[cssFile].source = cssMap.get(null)! + } + }, + } +} diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index ab23eb73..0ff84805 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -1,346 +1,355 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' - -import { - getCss, - getDefaultTheme, - getThemeInterface, - importClassMap, - importFileMap, - importSheet, - registerTheme, - setDebug, -} from '@devup-ui/wasm' -import { describe } from 'vitest' - -import { DevupUIWebpackPlugin } from '../plugin' - -vi.mock('@devup-ui/wasm') -vi.mock('node:fs') -vi.mock('node:fs/promises') - -beforeEach(() => { - vi.resetAllMocks() -}) -afterAll(() => { - vi.restoreAllMocks() -}) -function createCompiler() { - return { - options: { - module: { - rules: [], - }, - plugins: [], - }, - webpack: { - DefinePlugin: vi.fn(), - }, - hooks: { - watchRun: { - tapPromise: vi.fn(), - }, - beforeRun: { - tapPromise: vi.fn(), - }, - done: { - tapPromise: vi.fn(), - }, - afterCompile: { - tap: vi.fn(), - }, - }, - } as any -} - -describe('devupUIWebpackPlugin', () => { - console.error = vi.fn() - - it('should apply default options', () => { - expect(new DevupUIWebpackPlugin({}).options).toEqual({ - include: [], - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - devupFile: 'devup.json', - distDir: 'df', - watch: false, - debug: false, - singleCss: false, - }) - }) - - describe.each( - globalThis.createTestMatrix({ - watch: [true, false], - debug: [true, false], - singleCss: [true, false], - include: [['lib'], []], - package: ['@devup-ui/react', '@devup-ui/core'], - cssDir: [resolve('df', 'devup-ui'), resolve('df', 'devup-ui-core')], - devupFile: ['devup.json', 'devup-core.json'], - distDir: ['df', 'df-core'], - }), - )('options', (options) => { - it('should apply options', () => { - expect(new DevupUIWebpackPlugin(options).options).toEqual(options) - }) - - it.each( - createTestMatrix({ - readFile: [{ theme: 'theme' }, { theme: 'theme-core' }, undefined], - getThemeInterface: ['interfaceCode', ''], - getCss: ['css', 'css-core'], - }), - )('should write data files', async (_options) => { - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(_options.readFile)) - vi.mocked(getThemeInterface).mockReturnValue(_options.getThemeInterface) - vi.mocked(getCss).mockReturnValue(_options.getCss) - vi.mocked(existsSync).mockReturnValueOnce(_options.readFile !== undefined) - vi.mocked(writeFileSync).mockReturnValue() - vi.mocked(mkdirSync) - - const plugin = new DevupUIWebpackPlugin(options) - await plugin.writeDataFiles() - - if (_options.readFile !== undefined) { - expect(readFileSync).toHaveBeenCalledWith(options.devupFile, 'utf-8') - - expect(registerTheme).toHaveBeenCalledWith( - _options.readFile?.theme ?? {}, - ) - expect(getThemeInterface).toHaveBeenCalledWith( - options.package, - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - if (_options.getThemeInterface) - expect(writeFileSync).toHaveBeenCalledWith( - join(options.distDir, 'theme.d.ts'), - _options.getThemeInterface, - { - encoding: 'utf-8', - }, - ) - else expect(writeFileSync).toHaveBeenCalledTimes(options.watch ? 1 : 0) - } else expect(readFile).not.toHaveBeenCalled() - if (options.watch) - expect(writeFileSync).toHaveBeenCalledWith( - join(options.cssDir, 'devup-ui.css'), - _options.getCss, - ) - else - expect(writeFileSync).toHaveBeenCalledTimes( - _options.getThemeInterface && _options.readFile !== undefined ? 1 : 0, - ) - }) - }) - - it.each( - createTestMatrix({ - include: [ - { - input: ['lib'], - output: new RegExp( - '(node_modules(?!.*(@devup-ui|lib)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', - ), - }, - { - input: [], - output: new RegExp( - '(node_modules(?!.*(@devup-ui)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', - ), - }, - { - input: ['lib', 'lib2'], - output: new RegExp( - '(node_modules(?!.*(@devup-ui|lib|lib2)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', - ), - }, - ], - }), - )('should set include', async (options) => { - const plugin = new DevupUIWebpackPlugin({ - include: options.include.input, - }) - vi.mocked(existsSync).mockReturnValue(false) - vi.mocked(mkdir) - vi.mocked(writeFile) - vi.mocked(readFile) - vi.mocked(getThemeInterface) - vi.mocked(getCss) - vi.mocked(registerTheme) - vi.mocked(stat) - vi.mocked(readFile) - - const compiler = createCompiler() - await plugin.apply(compiler) - expect(compiler.options.module.rules.length).toBe(2) - - expect(compiler.options.module.rules[0].exclude).toEqual( - options.include.output, - ) - }) - - it.each( - createTestMatrix({ - debug: [true, false], - }), - )('should set debug', async (options) => { - const plugin = new DevupUIWebpackPlugin(options) - - const compiler = createCompiler() - - await plugin.apply(compiler) - expect(setDebug).toHaveBeenCalledWith(options.debug) - }) - - it('should reset data files when load error', async () => { - const plugin = new DevupUIWebpackPlugin({ - watch: true, - }) - const compiler = createCompiler() - vi.mocked(readFileSync).mockImplementation(() => { - throw new Error('error') - }) - vi.mocked(stat).mockReturnValue({ - mtimeMs: 1, - } as any) - vi.mocked(existsSync).mockReturnValue(true) - plugin.apply(compiler as any) - await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() - expect(importSheet).toHaveBeenCalledWith({}) - expect(importClassMap).toHaveBeenCalledWith({}) - expect(importFileMap).toHaveBeenCalledWith({}) - }) - - it.each( - createTestMatrix({ - watch: [true, false], - existsDevupFile: [true, false], - existsDistDir: [true, false], - existsSheetFile: [true, false], - existsClassMapFile: [true, false], - existsFileMapFile: [true, false], - existsCssDir: [true, false], - }), - )('should apply', async (options) => { - const plugin = new DevupUIWebpackPlugin({ - watch: options.watch, - }) - const compiler = createCompiler() - - vi.mocked(existsSync).mockImplementation((path) => { - if (path === plugin.options.devupFile) return options.existsDevupFile - if (path === plugin.options.distDir) return options.existsDistDir - if (path === plugin.options.cssDir) return options.existsCssDir - if (path === join(plugin.options.distDir, 'sheet.json')) - return options.existsSheetFile - if (path === join(plugin.options.distDir, 'classMap.json')) - return options.existsClassMapFile - if (path === join(plugin.options.distDir, 'fileMap.json')) - return options.existsFileMapFile - return false - }) - vi.mocked(getDefaultTheme).mockReturnValue('defaultTheme') - vi.mocked(stat).mockResolvedValueOnce({ - mtimeMs: 1, - } as any) - vi.mocked(stat).mockResolvedValueOnce({ - mtimeMs: 2, - } as any) - vi.mocked(mkdirSync) - if (options.existsSheetFile) - vi.mocked(readFileSync).mockReturnValueOnce('{"sheet": "sheet"}') - if (options.existsClassMapFile) - vi.mocked(readFileSync).mockReturnValueOnce('{"classMap": "classMap"}') - if (options.existsFileMapFile) - vi.mocked(readFileSync).mockReturnValueOnce('{"fileMap": "fileMap"}') - - plugin.apply(compiler) - - if (options.existsDistDir) - expect(mkdirSync).not.toHaveBeenCalledWith(plugin.options.distDir, { - recursive: true, - }) - else - expect(mkdirSync).toHaveBeenCalledWith(plugin.options.distDir, { - recursive: true, - }) - expect(writeFileSync).toHaveBeenCalledWith( - join(plugin.options.distDir, '.gitignore'), - '*', - 'utf-8', - ) - if (options.watch) { - if (options.existsSheetFile) - expect(importSheet).toHaveBeenCalledWith( - JSON.parse('{"sheet": "sheet"}'), - ) - if (options.existsClassMapFile) - expect(importClassMap).toHaveBeenCalledWith( - JSON.parse('{"classMap": "classMap"}'), - ) - if (options.existsFileMapFile) - expect(importFileMap).toHaveBeenCalledWith( - JSON.parse('{"fileMap": "fileMap"}'), - ) - expect(compiler.hooks.watchRun.tapPromise).toHaveBeenCalled() - - await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() - if (options.existsDevupFile) { - expect(stat).toHaveBeenCalledWith(plugin.options.devupFile) - await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() - } else { - expect(stat).not.toHaveBeenCalled() - } - } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() - if (options.existsDevupFile) { - expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() - const add = vi.fn() - compiler.hooks.afterCompile.tap.mock.calls[0][1]({ - fileDependencies: { - add, - }, - }) - expect(add).toHaveBeenCalledWith(resolve(plugin.options.devupFile)) - } else expect(compiler.hooks.afterCompile.tap).not.toHaveBeenCalled() - if (options.existsCssDir) { - expect(mkdir).not.toHaveBeenCalledWith(plugin.options.cssDir, { - recursive: true, - }) - } else { - expect(mkdirSync).toHaveBeenCalledWith(plugin.options.cssDir, { - recursive: true, - }) - } - - expect(compiler.webpack.DefinePlugin).toHaveBeenCalledWith({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), - }) - - if (!options.watch) { - expect(compiler.hooks.done.tapPromise).toHaveBeenCalled() - compiler.hooks.done.tapPromise.mock.calls[0][1]({ - hasErrors: () => true, - }) - expect(writeFile).not.toHaveBeenCalledWith( - join(plugin.options.cssDir, 'devup-ui.css'), - getCss(null, true), - 'utf-8', - ) - - await compiler.hooks.done.tapPromise.mock.calls[0][1]({ - hasErrors: () => false, - }) - expect(writeFile).toHaveBeenCalledWith( - join(plugin.options.cssDir, 'devup-ui.css'), - getCss(null, true), - 'utf-8', - ) - } else { - expect(compiler.hooks.done.tapPromise).not.toHaveBeenCalled() - } - }) -}) +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { + getCss, + getDefaultTheme, + getThemeInterface, + importClassMap, + importFileMap, + importSheet, + registerTheme, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import { describe } from 'vitest' + +import { DevupUIWebpackPlugin } from '../plugin' + +vi.mock('@devup-ui/wasm') +vi.mock('node:fs') +vi.mock('node:fs/promises') + +beforeEach(() => { + vi.resetAllMocks() +}) +afterAll(() => { + vi.restoreAllMocks() +}) +function createCompiler() { + return { + options: { + module: { + rules: [], + }, + plugins: [], + }, + webpack: { + DefinePlugin: vi.fn(), + }, + hooks: { + watchRun: { + tapPromise: vi.fn(), + }, + beforeRun: { + tapPromise: vi.fn(), + }, + done: { + tapPromise: vi.fn(), + }, + afterCompile: { + tap: vi.fn(), + }, + }, + } as any +} + +describe('devupUIWebpackPlugin', () => { + console.error = vi.fn() + + it('should apply default options', () => { + expect(new DevupUIWebpackPlugin({}).options).toEqual({ + include: [], + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + devupFile: 'devup.json', + distDir: 'df', + watch: false, + debug: false, + singleCss: false, + }) + }) + + describe.each( + globalThis.createTestMatrix({ + watch: [true, false], + debug: [true, false], + singleCss: [true, false], + include: [['lib'], []], + package: ['@devup-ui/react', '@devup-ui/core'], + cssDir: [resolve('df', 'devup-ui'), resolve('df', 'devup-ui-core')], + devupFile: ['devup.json', 'devup-core.json'], + distDir: ['df', 'df-core'], + }), + )('options', (options) => { + it('should apply options', () => { + expect(new DevupUIWebpackPlugin(options).options).toEqual(options) + }) + + it.each( + createTestMatrix({ + readFile: [{ theme: 'theme' }, { theme: 'theme-core' }, undefined], + getThemeInterface: ['interfaceCode', ''], + getCss: ['css', 'css-core'], + }), + )('should write data files', async (_options) => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(_options.readFile)) + vi.mocked(getThemeInterface).mockReturnValue(_options.getThemeInterface) + vi.mocked(getCss).mockReturnValue(_options.getCss) + vi.mocked(existsSync).mockReturnValueOnce(_options.readFile !== undefined) + vi.mocked(writeFileSync).mockReturnValue() + vi.mocked(mkdirSync) + + const plugin = new DevupUIWebpackPlugin(options) + await plugin.writeDataFiles() + + if (_options.readFile !== undefined) { + expect(readFileSync).toHaveBeenCalledWith(options.devupFile, 'utf-8') + + expect(registerTheme).toHaveBeenCalledWith( + _options.readFile?.theme ?? {}, + ) + expect(getThemeInterface).toHaveBeenCalledWith( + options.package, + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + if (_options.getThemeInterface) + expect(writeFileSync).toHaveBeenCalledWith( + join(options.distDir, 'theme.d.ts'), + _options.getThemeInterface, + { + encoding: 'utf-8', + }, + ) + else expect(writeFileSync).toHaveBeenCalledTimes(options.watch ? 1 : 0) + } else expect(readFile).not.toHaveBeenCalled() + if (options.watch) + expect(writeFileSync).toHaveBeenCalledWith( + join(options.cssDir, 'devup-ui.css'), + _options.getCss, + ) + else + expect(writeFileSync).toHaveBeenCalledTimes( + _options.getThemeInterface && _options.readFile !== undefined ? 1 : 0, + ) + }) + }) + + it.each( + createTestMatrix({ + include: [ + { + input: ['lib'], + output: new RegExp( + '(node_modules(?!.*(@devup-ui|lib)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', + ), + }, + { + input: [], + output: new RegExp( + '(node_modules(?!.*(@devup-ui)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', + ), + }, + { + input: ['lib', 'lib2'], + output: new RegExp( + '(node_modules(?!.*(@devup-ui|lib|lib2)([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)', + ), + }, + ], + }), + )('should set include', async (options) => { + const plugin = new DevupUIWebpackPlugin({ + include: options.include.input, + }) + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(mkdir) + vi.mocked(writeFile) + vi.mocked(readFile) + vi.mocked(getThemeInterface) + vi.mocked(getCss) + vi.mocked(registerTheme) + vi.mocked(stat) + vi.mocked(readFile) + + const compiler = createCompiler() + await plugin.apply(compiler) + expect(compiler.options.module.rules.length).toBe(2) + + expect(compiler.options.module.rules[0].exclude).toEqual( + options.include.output, + ) + }) + + it.each( + createTestMatrix({ + debug: [true, false], + }), + )('should set debug', async (options) => { + const plugin = new DevupUIWebpackPlugin(options) + + const compiler = createCompiler() + + await plugin.apply(compiler) + expect(setDebug).toHaveBeenCalledWith(options.debug) + }) + + it('should reset data files when load error', async () => { + const plugin = new DevupUIWebpackPlugin({ + watch: true, + }) + const compiler = createCompiler() + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('error') + }) + vi.mocked(stat).mockReturnValue({ + mtimeMs: 1, + } as any) + vi.mocked(existsSync).mockReturnValue(true) + plugin.apply(compiler as any) + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + expect(importSheet).toHaveBeenCalledWith({}) + expect(importClassMap).toHaveBeenCalledWith({}) + expect(importFileMap).toHaveBeenCalledWith({}) + }) + + it.each( + createTestMatrix({ + watch: [true, false], + existsDevupFile: [true, false], + existsDistDir: [true, false], + existsSheetFile: [true, false], + existsClassMapFile: [true, false], + existsFileMapFile: [true, false], + existsCssDir: [true, false], + }), + )('should apply', async (options) => { + const plugin = new DevupUIWebpackPlugin({ + watch: options.watch, + }) + const compiler = createCompiler() + + vi.mocked(existsSync).mockImplementation((path) => { + if (path === plugin.options.devupFile) return options.existsDevupFile + if (path === plugin.options.distDir) return options.existsDistDir + if (path === plugin.options.cssDir) return options.existsCssDir + if (path === join(plugin.options.distDir, 'sheet.json')) + return options.existsSheetFile + if (path === join(plugin.options.distDir, 'classMap.json')) + return options.existsClassMapFile + if (path === join(plugin.options.distDir, 'fileMap.json')) + return options.existsFileMapFile + return false + }) + vi.mocked(getDefaultTheme).mockReturnValue('defaultTheme') + vi.mocked(stat).mockResolvedValueOnce({ + mtimeMs: 1, + } as any) + vi.mocked(stat).mockResolvedValueOnce({ + mtimeMs: 2, + } as any) + vi.mocked(mkdirSync) + if (options.existsSheetFile) + vi.mocked(readFileSync).mockReturnValueOnce('{"sheet": "sheet"}') + if (options.existsClassMapFile) + vi.mocked(readFileSync).mockReturnValueOnce('{"classMap": "classMap"}') + if (options.existsFileMapFile) + vi.mocked(readFileSync).mockReturnValueOnce('{"fileMap": "fileMap"}') + + plugin.apply(compiler) + + if (options.existsDistDir) + expect(mkdirSync).not.toHaveBeenCalledWith(plugin.options.distDir, { + recursive: true, + }) + else + expect(mkdirSync).toHaveBeenCalledWith(plugin.options.distDir, { + recursive: true, + }) + expect(writeFileSync).toHaveBeenCalledWith( + join(plugin.options.distDir, '.gitignore'), + '*', + 'utf-8', + ) + if (options.watch) { + if (options.existsSheetFile) + expect(importSheet).toHaveBeenCalledWith( + JSON.parse('{"sheet": "sheet"}'), + ) + if (options.existsClassMapFile) + expect(importClassMap).toHaveBeenCalledWith( + JSON.parse('{"classMap": "classMap"}'), + ) + if (options.existsFileMapFile) + expect(importFileMap).toHaveBeenCalledWith( + JSON.parse('{"fileMap": "fileMap"}'), + ) + expect(compiler.hooks.watchRun.tapPromise).toHaveBeenCalled() + + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + if (options.existsDevupFile) { + expect(stat).toHaveBeenCalledWith(plugin.options.devupFile) + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + } else { + expect(stat).not.toHaveBeenCalled() + } + } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + if (options.existsDevupFile) { + expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() + const add = vi.fn() + compiler.hooks.afterCompile.tap.mock.calls[0][1]({ + fileDependencies: { + add, + }, + }) + expect(add).toHaveBeenCalledWith(resolve(plugin.options.devupFile)) + } else expect(compiler.hooks.afterCompile.tap).not.toHaveBeenCalled() + if (options.existsCssDir) { + expect(mkdir).not.toHaveBeenCalledWith(plugin.options.cssDir, { + recursive: true, + }) + } else { + expect(mkdirSync).toHaveBeenCalledWith(plugin.options.cssDir, { + recursive: true, + }) + } + + expect(compiler.webpack.DefinePlugin).toHaveBeenCalledWith({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + }) + + if (!options.watch) { + expect(compiler.hooks.done.tapPromise).toHaveBeenCalled() + compiler.hooks.done.tapPromise.mock.calls[0][1]({ + hasErrors: () => true, + }) + expect(writeFile).not.toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + getCss(null, true), + 'utf-8', + ) + + await compiler.hooks.done.tapPromise.mock.calls[0][1]({ + hasErrors: () => false, + }) + expect(writeFile).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + getCss(null, true), + 'utf-8', + ) + } else { + expect(compiler.hooks.done.tapPromise).not.toHaveBeenCalled() + } + }) + + it('should call setPrefix when prefix option is provided', async () => { + const plugin = new DevupUIWebpackPlugin({ prefix: 'my-prefix' }) + const compiler = createCompiler() + vi.mocked(existsSync).mockReturnValue(false) + plugin.apply(compiler) + expect(setPrefix).toHaveBeenCalledWith('my-prefix') + }) +}) diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 28a3ccce..7e72af5d 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,206 +1,213 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { stat, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' - -import { - getCss, - getDefaultTheme, - getThemeInterface, - importClassMap, - importFileMap, - importSheet, - registerTheme, - setDebug, -} from '@devup-ui/wasm' -import { type Compiler } from 'webpack' - -export interface DevupUIWebpackPluginOptions { - package: string - cssDir: string - devupFile: string - distDir: string - watch: boolean - debug: boolean - include: string[] - singleCss: boolean -} - -export class DevupUIWebpackPlugin { - options: DevupUIWebpackPluginOptions - sheetFile: string - classMapFile: string - fileMapFile: string - - constructor({ - package: libPackage = '@devup-ui/react', - devupFile = 'devup.json', - distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), - watch = false, - debug = false, - include = [], - singleCss = false, - }: Partial = {}) { - this.options = { - package: libPackage, - cssDir, - devupFile, - distDir, - watch, - debug, - include, - singleCss, - } - - this.sheetFile = join(this.options.distDir, 'sheet.json') - this.classMapFile = join(this.options.distDir, 'classMap.json') - this.fileMapFile = join(this.options.distDir, 'fileMap.json') - } - - writeDataFiles() { - try { - const content = existsSync(this.options.devupFile) - ? readFileSync(this.options.devupFile, 'utf-8') - : undefined - - if (content) { - registerTheme(JSON.parse(content)?.['theme'] ?? {}) - const interfaceCode = getThemeInterface( - this.options.package, - 'CustomColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - - if (interfaceCode) { - writeFileSync( - join(this.options.distDir, 'theme.d.ts'), - interfaceCode, - { - encoding: 'utf-8', - }, - ) - } - } else { - registerTheme({}) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - if (!existsSync(this.options.cssDir)) - mkdirSync(this.options.cssDir, { recursive: true }) - if (this.options.watch) - writeFileSync( - join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), - ) - } - - apply(compiler: Compiler) { - setDebug(this.options.debug) - const existsDevup = existsSync(this.options.devupFile) - // read devup.json - if (!existsSync(this.options.distDir)) - mkdirSync(this.options.distDir, { recursive: true }) - writeFileSync(join(this.options.distDir, '.gitignore'), '*', 'utf-8') - - if (this.options.watch) { - try { - // load sheet - if (existsSync(this.sheetFile)) - importSheet(JSON.parse(readFileSync(this.sheetFile, 'utf-8'))) - if (existsSync(this.classMapFile)) - importClassMap(JSON.parse(readFileSync(this.classMapFile, 'utf-8'))) - if (existsSync(this.fileMapFile)) - importFileMap(JSON.parse(readFileSync(this.fileMapFile, 'utf-8'))) - } catch (error) { - console.error(error) - importSheet({}) - importClassMap({}) - importFileMap({}) - } - } - this.writeDataFiles() - - if (this.options.watch) { - let lastModifiedTime: number | null = null - compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { - if (existsDevup) { - const stats = await stat(this.options.devupFile) - - const modifiedTime = stats.mtimeMs - if (lastModifiedTime && lastModifiedTime !== modifiedTime) - this.writeDataFiles() - - lastModifiedTime = modifiedTime - } - }) - } - if (existsDevup) - compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => { - compilation.fileDependencies.add(resolve(this.options.devupFile)) - }) - - compiler.options.plugins.push( - new compiler.webpack.DefinePlugin({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), - }), - ) - if (!this.options.watch) { - compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { - if (!stats.hasErrors()) { - // write css file - await writeFile( - join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ) - } - }) - } - - compiler.options.module.rules.push( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - exclude: new RegExp( - `(node_modules(?!.*(${['@devup-ui', ...this.options.include] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - enforce: 'pre', - use: [ - { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/loader', - ), - options: { - package: this.options.package, - cssDir: this.options.cssDir, - sheetFile: this.sheetFile, - classMapFile: this.classMapFile, - fileMapFile: this.fileMapFile, - watch: this.options.watch, - singleCss: this.options.singleCss, - }, - }, - ], - }, - { - test: this.options.cssDir, - enforce: 'pre', - use: [ - { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/css-loader', - ), - options: { - watch: this.options.watch, - }, - }, - ], - }, - ) - } -} +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { + getCss, + getDefaultTheme, + getThemeInterface, + importClassMap, + importFileMap, + importSheet, + registerTheme, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import { type Compiler } from 'webpack' + +export interface DevupUIWebpackPluginOptions { + package: string + cssDir: string + devupFile: string + distDir: string + watch: boolean + debug: boolean + include: string[] + singleCss: boolean + prefix?: string +} + +export class DevupUIWebpackPlugin { + options: DevupUIWebpackPluginOptions + sheetFile: string + classMapFile: string + fileMapFile: string + + constructor({ + package: libPackage = '@devup-ui/react', + devupFile = 'devup.json', + distDir = 'df', + cssDir = resolve(distDir, 'devup-ui'), + watch = false, + debug = false, + include = [], + singleCss = false, + prefix, + }: Partial = {}) { + this.options = { + package: libPackage, + cssDir, + devupFile, + distDir, + watch, + debug, + include, + singleCss, + prefix, + } + + this.sheetFile = join(this.options.distDir, 'sheet.json') + this.classMapFile = join(this.options.distDir, 'classMap.json') + this.fileMapFile = join(this.options.distDir, 'fileMap.json') + } + + writeDataFiles() { + try { + const content = existsSync(this.options.devupFile) + ? readFileSync(this.options.devupFile, 'utf-8') + : undefined + + if (content) { + registerTheme(JSON.parse(content)?.['theme'] ?? {}) + const interfaceCode = getThemeInterface( + this.options.package, + 'CustomColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + + if (interfaceCode) { + writeFileSync( + join(this.options.distDir, 'theme.d.ts'), + interfaceCode, + { + encoding: 'utf-8', + }, + ) + } + } else { + registerTheme({}) + } + } catch (error) { + console.error(error) + registerTheme({}) + } + if (!existsSync(this.options.cssDir)) + mkdirSync(this.options.cssDir, { recursive: true }) + if (this.options.watch) + writeFileSync( + join(this.options.cssDir, 'devup-ui.css'), + getCss(null, false), + ) + } + + apply(compiler: Compiler) { + setDebug(this.options.debug) + if (this.options.prefix) { + setPrefix(this.options.prefix) + } + const existsDevup = existsSync(this.options.devupFile) + // read devup.json + if (!existsSync(this.options.distDir)) + mkdirSync(this.options.distDir, { recursive: true }) + writeFileSync(join(this.options.distDir, '.gitignore'), '*', 'utf-8') + + if (this.options.watch) { + try { + // load sheet + if (existsSync(this.sheetFile)) + importSheet(JSON.parse(readFileSync(this.sheetFile, 'utf-8'))) + if (existsSync(this.classMapFile)) + importClassMap(JSON.parse(readFileSync(this.classMapFile, 'utf-8'))) + if (existsSync(this.fileMapFile)) + importFileMap(JSON.parse(readFileSync(this.fileMapFile, 'utf-8'))) + } catch (error) { + console.error(error) + importSheet({}) + importClassMap({}) + importFileMap({}) + } + } + this.writeDataFiles() + + if (this.options.watch) { + let lastModifiedTime: number | null = null + compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { + if (existsDevup) { + const stats = await stat(this.options.devupFile) + + const modifiedTime = stats.mtimeMs + if (lastModifiedTime && lastModifiedTime !== modifiedTime) + this.writeDataFiles() + + lastModifiedTime = modifiedTime + } + }) + } + if (existsDevup) + compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => { + compilation.fileDependencies.add(resolve(this.options.devupFile)) + }) + + compiler.options.plugins.push( + new compiler.webpack.DefinePlugin({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + }), + ) + if (!this.options.watch) { + compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { + if (!stats.hasErrors()) { + // write css file + await writeFile( + join(this.options.cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ) + } + }) + } + + compiler.options.module.rules.push( + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + exclude: new RegExp( + `(node_modules(?!.*(${['@devup-ui', ...this.options.include] + .join('|') + .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + ), + enforce: 'pre', + use: [ + { + loader: createRequire(import.meta.url).resolve( + '@devup-ui/webpack-plugin/loader', + ), + options: { + package: this.options.package, + cssDir: this.options.cssDir, + sheetFile: this.sheetFile, + classMapFile: this.classMapFile, + fileMapFile: this.fileMapFile, + watch: this.options.watch, + singleCss: this.options.singleCss, + }, + }, + ], + }, + { + test: this.options.cssDir, + enforce: 'pre', + use: [ + { + loader: createRequire(import.meta.url).resolve( + '@devup-ui/webpack-plugin/css-loader', + ), + options: { + watch: this.options.watch, + }, + }, + ], + }, + ) + } +}