diff --git a/.changeset/tough-lizards-train.md b/.changeset/tough-lizards-train.md new file mode 100644 index 00000000..6c8e1083 --- /dev/null +++ b/.changeset/tough-lizards-train.md @@ -0,0 +1,5 @@ +--- +'@devup-ui/next-plugin': patch +--- + +Create Theme type file diff --git a/.changeset/young-poets-tease.md b/.changeset/young-poets-tease.md new file mode 100644 index 00000000..c460af9f --- /dev/null +++ b/.changeset/young-poets-tease.md @@ -0,0 +1,5 @@ +--- +'@devup-ui/next-plugin': patch +--- + +Using next-turbo loader diff --git a/packages/next-plugin/package.json b/packages/next-plugin/package.json index feb37d02..43517b67 100644 --- a/packages/next-plugin/package.json +++ b/packages/next-plugin/package.json @@ -34,6 +34,14 @@ ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./css-loader": { + "import": "./dist/css-loader.js", + "require": "./dist/css-loader.cjs" + }, + "./loader": { + "import": "./dist/loader.js", + "require": "./dist/loader.cjs" } }, "files": [ diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts new file mode 100644 index 00000000..51f8eba9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -0,0 +1,66 @@ +import { resolve } from 'node:path' + +import { getCss } from '@devup-ui/wasm' + +import devupUICssLoader from '../css-loader' + +vi.mock('node:path') +vi.mock('@devup-ui/wasm', () => ({ + registerTheme: vi.fn(), + getCss: vi.fn(), +})) + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('devupUICssLoader', () => { + it('should return css on no watch', () => { + const callback = vi.fn() + const addContextDependency = vi.fn() + vi.mocked(resolve).mockReturnValue('resolved') + vi.mocked(getCss).mockReturnValue('get css') + devupUICssLoader.bind({ + callback, + addContextDependency, + resourcePath: 'devup-ui.css', + getOptions: () => ({ watch: false }), + } as any)(Buffer.from('data'), '') + expect(callback).toBeCalledWith(null, 'get css', '', undefined) + }) + + it('should return _compiler hit css on watch', () => { + const callback = vi.fn() + const addContextDependency = vi.fn() + vi.mocked(resolve).mockReturnValue('resolved') + vi.mocked(getCss).mockReturnValue('get css') + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ watch: true }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from('data'), '') + expect(callback).toBeCalledWith(null, 'get css', '', undefined) + expect(getCss).toBeCalledTimes(1) + vi.mocked(getCss).mockReset() + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ watch: true }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from('data'), '') + + expect(getCss).toBeCalledTimes(1) + + vi.mocked(getCss).mockReset() + + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ watch: true }), + resourcePath: 'devup-ui-10.css', + } as any)(Buffer.from(''), '') + + expect(getCss).toBeCalledTimes(1) + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts new file mode 100644 index 00000000..6d3183f6 --- /dev/null +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -0,0 +1,313 @@ +import { writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' + +import { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, +} from '@devup-ui/wasm' + +vi.mock('@devup-ui/wasm') +vi.mock('node:fs/promises') +vi.mock('node:path', async (original: any) => { + const origin = await original() + return { + ...origin, + relative: vi.fn(origin.relative), + } +}) + +beforeEach(() => { + vi.resetAllMocks() + vi.resetModules() + Date.now = vi.fn().mockReturnValue(0) +}) + +describe('devupUILoader', () => { + it.each( + createTestMatrix({ + updatedBaseStyle: [true, false], + }), + )('should extract code with css', async (options) => { + const { default: devupUILoader } = await import('../loader') + const _compiler = { + __DEVUP_CACHE: '', + } + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + _compiler, + } + vi.mocked(exportSheet).mockReturnValue('sheet') + vi.mocked(exportClassMap).mockReturnValue('classMap') + vi.mocked(exportFileMap).mockReturnValue('fileMap') + vi.mocked(getCss).mockReturnValue('css') + + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: '{}', + cssFile: 'cssFile', + updatedBaseStyle: options.updatedBaseStyle, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(t.async).toHaveBeenCalled() + expect(codeExtract).toHaveBeenCalledWith( + 'index.tsx', + 'code', + 'package', + './cssFile', + true, + false, + true, + ) + if (options.updatedBaseStyle) { + expect(writeFile).toHaveBeenCalledWith( + join('cssFile', 'devup-ui.css'), + 'css', + 'utf-8', + ) + } else { + expect(writeFile).not.toHaveBeenCalledWith( + join('cssFile', 'devup-ui.css'), + 'css', + 'utf-8', + ) + } + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(null, 'code', {}) + expect(writeFile).toHaveBeenCalledWith( + join('cssFile', 'cssFile'), + '/* index.tsx 0 */', + ) + expect(writeFile).toHaveBeenCalledWith('sheetFile', 'sheet') + expect(writeFile).toHaveBeenCalledWith('classMapFile', 'classMap') + expect(writeFile).toHaveBeenCalledWith('fileMapFile', 'fileMap') + }) + }) + + it('should extract code without css', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: undefined, + free: vi.fn(), + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(t.async).toHaveBeenCalled() + expect(codeExtract).toHaveBeenCalledWith( + 'index.tsx', + 'code', + 'package', + './cssFile', + true, + false, + true, + ) + expect(t.async()).toHaveBeenCalledWith(null, 'code', null) + expect(writeFile).not.toHaveBeenCalledWith('cssFile', 'css', { + encoding: 'utf-8', + }) + }) + + it('should handle error', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(codeExtract).mockImplementation(() => { + throw new Error('error') + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(t.async).toHaveBeenCalled() + expect(t.async()).toHaveBeenCalledWith(new Error('error')) + }) + + it('should load with date now on watch', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: true, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: 'cssFile', + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(t.async).toHaveBeenCalled() + expect(codeExtract).toHaveBeenCalledWith( + 'index.tsx', + 'code', + 'package', + './cssFile', + true, + false, + true, + ) + }) + + it('should load with nowatch', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: './foo', + watch: false, + singleCss: true, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: './foo/index.tsx', + addDependency: vi.fn(), + } + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: 'cssFile', + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + vi.mocked(relative).mockReturnValue('./foo/index.tsx') + devupUILoader.bind(t as any)(Buffer.from('code'), '/foo/index.tsx') + }) + it('should load with theme', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + theme: { + colors: { + primary: '#000', + }, + }, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + vi.mocked(registerTheme).mockReturnValueOnce(undefined) + vi.mocked(codeExtract).mockReturnValue({ + code: 'code', + css: 'css', + free: vi.fn(), + map: undefined, + cssFile: 'cssFile', + updatedBaseStyle: false, + [Symbol.dispose]: vi.fn(), + }) + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + expect(registerTheme).toHaveBeenCalledWith({ + colors: { + primary: '#000', + }, + }) + }) + + it('should register theme on init', async () => { + const { default: devupUILoader } = await import('../loader') + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssFile', + watch: false, + singleCss: true, + theme: { + colors: { + primary: '#000', + }, + }, + defaultClassMap: { + button: 'button', + }, + defaultFileMap: { + button: 'button', + }, + defaultSheet: { + button: 'button', + }, + }), + async: vi.fn().mockReturnValue(vi.fn()), + resourcePath: 'index.tsx', + addDependency: vi.fn(), + } + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + expect(registerTheme).toHaveBeenCalledTimes(1) + expect(importClassMap).toHaveBeenCalledWith({ + button: 'button', + }) + expect(importFileMap).toHaveBeenCalledWith({ + button: 'button', + }) + expect(importSheet).toHaveBeenCalledWith({ + button: 'button', + }) + + devupUILoader.bind(t as any)(Buffer.from('code'), 'index.tsx') + + expect(registerTheme).toHaveBeenCalledTimes(1) + expect(importClassMap).toHaveBeenCalledTimes(1) + expect(importFileMap).toHaveBeenCalledTimes(1) + expect(importSheet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index ee058dcf..a436e6f7 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import { getThemeInterface } from '@devup-ui/wasm' import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin' import { DevupUI } from '../plugin' @@ -9,6 +10,10 @@ 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()), + getThemeInterface: vi.fn(), +})) describe('DevupUINextPlugin', () => { describe('webpack', () => { @@ -84,13 +89,13 @@ describe('DevupUINextPlugin', () => { rules: { './df/devup-ui/*.css': [ { - loader: '@devup-ui/webpack-plugin/css-loader', + loader: '@devup-ui/next-plugin/css-loader', }, ], '*.{tsx,ts,js,mjs}': { loaders: [ { - loader: '@devup-ui/webpack-plugin/loader', + loader: '@devup-ui/next-plugin/loader', options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), @@ -139,7 +144,7 @@ describe('DevupUINextPlugin', () => { rules: { './df/devup-ui/*.css': [ { - loader: '@devup-ui/webpack-plugin/css-loader', + loader: '@devup-ui/next-plugin/css-loader', }, ], '*.{tsx,ts,js,mjs}': { @@ -154,7 +159,7 @@ describe('DevupUINextPlugin', () => { }, loaders: [ { - loader: '@devup-ui/webpack-plugin/loader', + loader: '@devup-ui/next-plugin/loader', options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), @@ -201,7 +206,7 @@ describe('DevupUINextPlugin', () => { rules: { './df/devup-ui/*.css': [ { - loader: '@devup-ui/webpack-plugin/css-loader', + loader: '@devup-ui/next-plugin/css-loader', }, ], '*.{tsx,ts,js,mjs}': { @@ -216,7 +221,7 @@ describe('DevupUINextPlugin', () => { }, loaders: [ { - loader: '@devup-ui/webpack-plugin/loader', + loader: '@devup-ui/next-plugin/loader', options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), @@ -270,5 +275,24 @@ describe('DevupUINextPlugin', () => { 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'), '*') + }) }) }) diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts new file mode 100644 index 00000000..eecd71b7 --- /dev/null +++ b/packages/next-plugin/src/css-loader.ts @@ -0,0 +1,13 @@ +import { getCss } from '@devup-ui/wasm' +import type { RawLoaderDefinitionFunction } from 'webpack' + +function getFileNumByFilename(filename: string) { + if (filename.endsWith('devup-ui.css')) return null + return parseInt(filename.split('devup-ui-')[1].split('.')[0]) +} + +const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { + const fileNum = getFileNumByFilename(this.resourcePath) + this.callback(null, getCss(fileNum, true), map, meta) +} +export default devupUICssLoader diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts new file mode 100644 index 00000000..3ee7077a --- /dev/null +++ b/packages/next-plugin/src/loader.ts @@ -0,0 +1,114 @@ +import { writeFile } from 'node:fs/promises' +import { basename, dirname, join, relative } from 'node:path' + +import { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, +} from '@devup-ui/wasm' +import type { RawLoaderDefinitionFunction } from 'webpack' + +export interface DevupUILoaderOptions { + package: string + cssDir: string + sheetFile: string + classMapFile: string + fileMapFile: string + watch: boolean + singleCss: boolean + // turbo + theme?: object + defaultSheet: object + defaultClassMap: object + defaultFileMap: object +} +let init = false + +const devupUILoader: RawLoaderDefinitionFunction = + function (source) { + const { + watch, + package: libPackage, + cssDir, + sheetFile, + classMapFile, + fileMapFile, + singleCss, + theme, + defaultClassMap, + defaultFileMap, + defaultSheet, + } = this.getOptions() + const callback = this.async() + const id = this.resourcePath + if (!init) { + init = true + if (defaultFileMap) importFileMap(defaultFileMap) + if (defaultClassMap) importClassMap(defaultClassMap) + if (defaultSheet) importSheet(defaultSheet) + if (theme) registerTheme(theme) + } + + try { + let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') + + const relativePath = relative(process.cwd(), id) + + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + const { + code, + css = '', + map, + cssFile, + updatedBaseStyle, + } = codeExtract( + relativePath, + source.toString(), + libPackage, + relCssDir, + singleCss, + false, + true, + ) + const sourceMap = map ? JSON.parse(map) : null + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style + promises.push( + writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + ) + } + if (cssFile) { + const content = `${this.resourcePath} ${Date.now()}` + // should be reset css + promises.push( + writeFile( + join(cssDir, basename(cssFile!)), + watch ? `/* ${content} */` : css, + ), + ) + if (watch) { + promises.push( + writeFile(sheetFile, exportSheet()), + writeFile(classMapFile, exportClassMap()), + writeFile(fileMapFile, exportFileMap()), + ) + } + Promise.all(promises) + .catch(console.error) + .finally(() => callback(null, code, sourceMap)) + return + } + callback(null, code, sourceMap) + } catch (error) { + callback(error as Error) + } + return + } +export default devupUILoader diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 0d678188..35a7e2cd 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,7 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' -import { exportClassMap, exportFileMap, exportSheet } from '@devup-ui/wasm' +import { + exportClassMap, + exportFileMap, + exportSheet, + getThemeInterface, +} from '@devup-ui/wasm' import { DevupUIWebpackPlugin, type DevupUIWebpackPluginOptions, @@ -61,6 +66,15 @@ export function DevupUI( const theme = existsSync(devupFile) ? JSON.parse(readFileSync(devupFile, 'utf-8'))?.['theme'] : {} + const themeInterface = getThemeInterface( + libPackage, + 'DevupThemeColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + if (themeInterface) { + writeFileSync(join(distDir, 'theme.d.ts'), themeInterface) + } // disable turbo parallel const excludeRegex = new RegExp( `node_modules(?!.*(${['@devup-ui', ...include] @@ -81,13 +95,13 @@ export function DevupUI( const rules: NonNullable = { [`./${relative(process.cwd(), cssDir).replaceAll('\\', '/')}/*.css`]: [ { - loader: '@devup-ui/webpack-plugin/css-loader', + loader: '@devup-ui/next-plugin/css-loader', }, ], '*.{tsx,ts,js,mjs}': { loaders: [ { - loader: '@devup-ui/webpack-plugin/loader', + loader: '@devup-ui/next-plugin/loader', options: { package: libPackage, cssDir, diff --git a/packages/next-plugin/tsconfig.json b/packages/next-plugin/tsconfig.json index 4c6e75be..fa3c2eb3 100644 --- a/packages/next-plugin/tsconfig.json +++ b/packages/next-plugin/tsconfig.json @@ -22,5 +22,7 @@ "noEmit": true, "baseUrl": ".", "jsx": "react-jsx" - } + }, + "include": ["src", "vite.config.ts", "../../vitest.setup.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/next-plugin/vite.config.ts b/packages/next-plugin/vite.config.ts index 56da018a..09f11c5b 100644 --- a/packages/next-plugin/vite.config.ts +++ b/packages/next-plugin/vite.config.ts @@ -56,6 +56,8 @@ export default defineConfig({ formats: ['es', 'cjs'], entry: { index: 'src/index.ts', + ['css-loader']: 'src/css-loader.ts', + loader: 'src/loader.ts', }, }, outDir: 'dist',