diff --git a/.changeset/tricky-planes-shake.md b/.changeset/tricky-planes-shake.md new file mode 100644 index 0000000000..c2785cbb64 --- /dev/null +++ b/.changeset/tricky-planes-shake.md @@ -0,0 +1,35 @@ +--- +"flowbite-react": minor +--- + +# Breaking changes + +- removed `flowbite-react patch` CLI command + +## Changes + +- `flowbite-react/.gitignore`, `flowbite-react/config.json` self manages, regenerates and repairs +- new generated file `init.(jsx|tsx)` in `.flowbite-react/` directory that syncs up values from `config.json` that also are needed in React app runtime (similar to how a React context works) + - synced on CLI commands: `init`, `build`, `dev`, `register` +- If you have custom configuration in `.flowbite-react/config.json` (different `dark`/`prefix`/`version` values), you must render `` at the root level of your app to sync runtime with node config values + - notify users to include `` at the root level if custom `dark`, `prefix`, or `version` values are detected in the configuration file +- expose `flowbite-react/store` import path +- check if `flowbite-react` is installed when `npx flowbite-react@latest init` + - bump the version to latest if below `0.11.x` +- remove redundant `{ flag: "w" }` in `fs.writeFile` +- update `dark-mode.md` and `prefix.md` documentation to reflect the `` changes +- add Tailwind CSS version support in theme mode handling and fix dark theme toggle class in Tailwind CSS v4 + +## Migration Guide + +1. Remove `flowbite-react patch` from your `package.json` + + ```diff + { + "scripts": { + - "postinstall": "flowbite-react patch" + } + } + ``` + +2. Add `` (import from `.flowbite-react/init.(jsx|tsx)`) at the root level of your app if you have custom configuration in `.flowbite-react/config.json` (different `dark`/`prefix`/`version` values). diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 243706b0a7..fd7c3264cb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,7 +7,7 @@ runs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.2 + bun-version: 1.2.18 - name: Setup Node uses: actions/setup-node@v4 diff --git a/apps/storybook/.flowbite-react/config.json b/apps/storybook/.flowbite-react/config.json index c987f54cc8..b861718e37 100644 --- a/apps/storybook/.flowbite-react/config.json +++ b/apps/storybook/.flowbite-react/config.json @@ -1,9 +1,10 @@ { - "$schema": "../../../node_modules/flowbite-react/schema.json", + "$schema": "https://unpkg.com/flowbite-react/schema.json", "components": [], "dark": true, "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 3 } diff --git a/apps/storybook/.flowbite-react/init.tsx b/apps/storybook/.flowbite-react/init.tsx new file mode 100644 index 0000000000..1ef82b42b1 --- /dev/null +++ b/apps/storybook/.flowbite-react/init.tsx @@ -0,0 +1,22 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore-all lint: auto-generated file + +// This file is auto-generated by the flowbite-react CLI. +// Do not edit this file directly. +// Instead, edit the .flowbite-react/config.json file. + +import { StoreInit } from "flowbite-react/store/init"; +import React from "react"; + +export const config = { + dark: true, + prefix: "", + version: 3, +}; + +export function ThemeInit() { + return ; +} + +ThemeInit.displayName = "ThemeInit"; diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.tsx similarity index 64% rename from apps/storybook/.storybook/preview.ts rename to apps/storybook/.storybook/preview.tsx index bd89877adc..39e20432bc 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.tsx @@ -1,5 +1,7 @@ import { withThemeByClassName } from "@storybook/addon-themes"; -import type { Preview } from "@storybook/react"; +import type { Decorator, Preview } from "@storybook/react"; +import React from "react"; +import { ThemeInit } from "../.flowbite-react/init"; import "./style.css"; @@ -17,7 +19,13 @@ const preview: Preview = { }, }; -export const decorators = [ +export const decorators: Decorator[] = [ + (Story) => ( + <> + + + + ), withThemeByClassName({ themes: { light: "light", diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index 21dd10605c..011de0cee9 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -21,5 +21,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "storybook-static"] } diff --git a/apps/web/.flowbite-react/config.json b/apps/web/.flowbite-react/config.json index 5145e8553a..60d26e6446 100644 --- a/apps/web/.flowbite-react/config.json +++ b/apps/web/.flowbite-react/config.json @@ -1,9 +1,10 @@ { - "$schema": "../../../node_modules/flowbite-react/schema.json", + "$schema": "https://unpkg.com/flowbite-react/schema.json", "components": [], "dark": true, "path": "components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 3 } diff --git a/apps/web/.flowbite-react/init.tsx b/apps/web/.flowbite-react/init.tsx new file mode 100644 index 0000000000..1ef82b42b1 --- /dev/null +++ b/apps/web/.flowbite-react/init.tsx @@ -0,0 +1,22 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore-all lint: auto-generated file + +// This file is auto-generated by the flowbite-react CLI. +// Do not edit this file directly. +// Instead, edit the .flowbite-react/config.json file. + +import { StoreInit } from "flowbite-react/store/init"; +import React from "react"; + +export const config = { + dark: true, + prefix: "", + version: 3, +}; + +export function ThemeInit() { + return ; +} + +ThemeInit.displayName = "ThemeInit"; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 65fb77e9dd..9ef2a042c2 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter as InterFont } from "next/font/google"; import type { Metadata, Viewport } from "next/types"; import type { PropsWithChildren } from "react"; import { FathomScript } from "~/components/fathom-script"; +import { ThemeInit } from "../.flowbite-react/init"; import "~/styles/globals.css"; @@ -60,6 +61,7 @@ export default function RootLayout({ children }: PropsWithChildren) { + {children} diff --git a/apps/web/content/docs/customize/config.mdx b/apps/web/content/docs/customize/config.mdx index 1d44154148..e77e7c5a12 100644 --- a/apps/web/content/docs/customize/config.mdx +++ b/apps/web/content/docs/customize/config.mdx @@ -40,7 +40,8 @@ The configuration file follows this JSON Schema: }, "prefix": { "description": "Optional prefix to apply to all Tailwind CSS classes", - "type": "string" + "type": "string", + "default": "" }, "rsc": { "description": "Whether to include the 'use client' directive for React Server Components", @@ -51,9 +52,15 @@ The configuration file follows this JSON Schema: "description": "Whether to use TypeScript (.tsx) or JavaScript (.jsx) for component creation", "type": "boolean", "default": true + }, + "version": { + "description": "The version of Tailwind CSS to use", + "type": "number", + "enum": [3, 4], + "default": 4 } }, - "required": ["components", "dark", "path", "prefix", "rsc", "tsx"] + "required": ["components", "dark", "path", "prefix", "rsc", "tsx", "version"] } ``` @@ -101,6 +108,13 @@ For detailed instructions on configuring and using prefixes, see the [Prefix](/d - Default: `true` - Description: Whether to use TypeScript (.tsx) or JavaScript (.jsx) for component creation. When set to `false`, components will be created with .jsx extension. +#### `version` + +- Type: `number` +- Options: `3`, `4` +- Default: `4` +- Description: The version of Tailwind CSS to use. + ## Automatic Class Generation The automatic class generation system works in two modes: @@ -120,10 +134,12 @@ Example config for automatic mode: { "$schema": "https://unpkg.com/flowbite-react/schema.json", "components": [], + "dark": true, "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` @@ -142,10 +158,12 @@ Example config for manual mode: { "$schema": "https://unpkg.com/flowbite-react/schema.json", "components": ["Button", "Card", "Modal"], + "dark": true, "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` diff --git a/apps/web/content/docs/customize/custom-components.mdx b/apps/web/content/docs/customize/custom-components.mdx index 261f4b3055..69f63d8c05 100644 --- a/apps/web/content/docs/customize/custom-components.mdx +++ b/apps/web/content/docs/customize/custom-components.mdx @@ -25,10 +25,12 @@ You can customize these options in your config file `.flowbite-react/config.json { "$schema": "https://unpkg.com/flowbite-react/schema.json", "components": [], + "dark": true, "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` diff --git a/apps/web/content/docs/customize/dark-mode.mdx b/apps/web/content/docs/customize/dark-mode.mdx index 4b055fd68c..e1c6fc08d9 100644 --- a/apps/web/content/docs/customize/dark-mode.mdx +++ b/apps/web/content/docs/customize/dark-mode.mdx @@ -55,28 +55,9 @@ declare const useThemeMode: () => { To completely disable dark mode class generation in your Flowbite React application, you should use both of the following methods: -### 1. Using the ThemeConfig Component +### 1. Using the Configuration File -First, disable dark mode at the application level by setting the `dark` prop to `false` in the `ThemeConfig` component: - -```jsx -import { ThemeConfig } from "flowbite-react"; - -function App() { - return ( - <> - - {/* Your application */} - - ); -} -``` - -This approach prevents the dark mode toggle functionality from working in your application's runtime. - -### 2. Using the Configuration File - -Additionally, you must disable dark mode in your build configuration by setting the `dark` property to `false` in your `.flowbite-react/config.json` file: +First, disable dark mode in your build configuration by setting the `dark` property to `false` in your `.flowbite-react/config.json` file: ```json {4} { @@ -86,13 +67,32 @@ Additionally, you must disable dark mode in your build configuration by setting "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` This method prevents dark mode classes from being generated during the build process, which reduces your CSS bundle size. -> **Important**: For complete dark mode disabling, both methods should be used together. The ThemeConfig approach affects runtime behavior, while the config.json approach affects build-time class generation. +### 2. Using the ThemeInit Component + +When you have custom configuration in your `.flowbite-react/config.json` (including disabling dark mode), you must render the `ThemeInit` component at the root level of your application to sync the runtime with your configuration: + +```jsx +// src/App.tsx +import { ThemeInit } from "../.flowbite-react/init"; + +function App() { + return ( + <> + + {/* Your application */} + + ); +} +``` + +> **Important**: The configuration file approach affects build-time class generation, while the ThemeInit component ensures your runtime behavior matches your configuration. ## Framework Integration diff --git a/apps/web/content/docs/customize/prefix.mdx b/apps/web/content/docs/customize/prefix.mdx index 12a14a5d42..e8e1b65f30 100644 --- a/apps/web/content/docs/customize/prefix.mdx +++ b/apps/web/content/docs/customize/prefix.mdx @@ -19,7 +19,8 @@ To set a custom prefix for Flowbite React components, modify the `prefix` proper "path": "components", "prefix": "tw", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` @@ -49,16 +50,17 @@ In Tailwind CSS v4, the prefix cannot contain special characters (like hyphens). ## Update Your React Application -Next, render the `ThemeConfig` component at the **root of your app** with the same `prefix` property: +When you have custom configuration in your `.flowbite-react/config.json` (including a custom prefix), you must render the `ThemeInit` component at the root level of your application to sync the runtime with your configuration: -```tsx {1,6} -import { ThemeConfig } from "flowbite-react"; +```tsx +// src/App.tsx +import { ThemeInit } from "../.flowbite-react/init"; export default function App() { return ( <> - - {/* ... */} + + {/* Your application */} ); } diff --git a/apps/web/content/docs/getting-started/cli.mdx b/apps/web/content/docs/getting-started/cli.mdx index a9b5050c21..1f7d912114 100644 --- a/apps/web/content/docs/getting-started/cli.mdx +++ b/apps/web/content/docs/getting-started/cli.mdx @@ -12,7 +12,6 @@ The Flowbite React CLI provides a comprehensive set of tools for: - Managing development workflows - Handling class generation - Configuring your development environment -- Patching Tailwind CSS configurations - Providing help and documentation ## Installation & Setup @@ -149,15 +148,6 @@ import { AccordionPanel } from "flowbite-react"; ... ``` -### `flowbite-react patch` - -Patches Tailwind CSS to expose its version number for compatibility detection: - -- Creates a JavaScript file that exports the Tailwind CSS version -- Necessary because package.json cannot be reliably read by all bundlers -- Makes the version accessible via `import version from "tailwindcss/version"` -- Enables Flowbite React to adapt its behavior based on the installed Tailwind version - ### `flowbite-react register` Registers the Flowbite React process for development: @@ -232,7 +222,8 @@ The CLI creates a `.flowbite-react/config.json` file with several configuration "path": "src/components", "prefix": "", "rsc": true, - "tsx": true + "tsx": true, + "version": 4 } ``` @@ -260,6 +251,10 @@ Whether to include the `"use client"` directive for React Server Components. Def Whether to use TypeScript (.tsx) or JavaScript (.jsx) for component creation. Default is `true`. +#### `version` + +The version of Tailwind CSS to use. Default is `4`. + ### VSCode Integration The CLI sets up VSCode for optimal development: diff --git a/package.json b/package.json index 244e12cd13..2d18c5d42c 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,5 @@ "prettier-plugin-tailwindcss": "0.6.11", "turbo": "2.4.4" }, - "packageManager": "bun@1.2.4" + "packageManager": "bun@1.2.18" } diff --git a/packages/ui/package.json b/packages/ui/package.json index 571a1c13b4..fc13eb96b4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -162,6 +162,46 @@ "default": "./dist/plugin/tailwindcss/*.cjs" } }, + "./store": { + "import": { + "types": "./dist/store/index.d.ts", + "default": "./dist/store/index.js" + }, + "require": { + "types": "./dist/store/index.d.cts", + "default": "./dist/store/index.cjs" + } + }, + "./store/*": { + "import": { + "types": "./dist/store/*.d.ts", + "default": "./dist/store/*.js" + }, + "require": { + "types": "./dist/store/*.d.cts", + "default": "./dist/store/*.cjs" + } + }, + "./store/init": { + "import": { + "types": "./dist/store/init/index.d.ts", + "default": "./dist/store/init/index.js" + }, + "require": { + "types": "./dist/store/init/index.d.cts", + "default": "./dist/store/init/index.cjs" + } + }, + "./store/init/*": { + "import": { + "types": "./dist/store/init/*.d.ts", + "default": "./dist/store/init/*.js" + }, + "require": { + "types": "./dist/store/init/*.d.cts", + "default": "./dist/store/init/*.cjs" + } + }, "./theme": { "import": { "types": "./dist/theme/index.d.ts", @@ -212,7 +252,6 @@ "format": "prettier . --write", "format:check": "prettier . --check", "generate-metadata": "bun scripts/generate-metadata.ts", - "postinstall": "bun run src/cli/bin.ts patch", "lint": "eslint .", "lint:fix": "eslint . --fix", "prepack": "clean-package", diff --git a/packages/ui/rollup.config.js b/packages/ui/rollup.config.js index 65d77fcc19..55e7c49041 100644 --- a/packages/ui/rollup.config.js +++ b/packages/ui/rollup.config.js @@ -18,7 +18,6 @@ const external = [ "react/jsx-runtime", "readline", "tailwindcss/plugin", - "tailwindcss/version.js", ...Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies, diff --git a/packages/ui/schema.json b/packages/ui/schema.json index f645317884..0d43ff9666 100644 --- a/packages/ui/schema.json +++ b/packages/ui/schema.json @@ -128,7 +128,8 @@ }, "prefix": { "description": "Optional prefix to apply to all Tailwind CSS classes. \nSee https://flowbite-react.com/docs/customize/config#prefix for more details.", - "type": "string" + "type": "string", + "default": "" }, "rsc": { "description": "Whether to include the 'use client' directive for React Server Components. \nSee https://flowbite-react.com/docs/customize/config#rsc for more details.", @@ -139,7 +140,13 @@ "description": "Whether to use TypeScript (.tsx) or JavaScript (.jsx) for component creation. \nSee https://flowbite-react.com/docs/customize/config#tsx for more details.", "type": "boolean", "default": true + }, + "version": { + "description": "The version of Tailwind CSS to use. \nSee https://flowbite-react.com/docs/customize/config#version for more details.", + "type": "number", + "enum": [3, 4], + "default": 4 } }, - "required": ["components", "dark", "path", "prefix", "rsc", "tsx"] + "required": ["components", "dark", "path", "prefix", "rsc", "tsx", "version"] } diff --git a/packages/ui/scripts/generate-metadata.ts b/packages/ui/scripts/generate-metadata.ts index c49c432813..7bee096917 100644 --- a/packages/ui/scripts/generate-metadata.ts +++ b/packages/ui/scripts/generate-metadata.ts @@ -235,6 +235,7 @@ async function generateSchema(components: string[]): Promise { description: "Optional prefix to apply to all Tailwind CSS classes. \nSee https://flowbite-react.com/docs/customize/config#prefix for more details.", type: "string", + default: "", }, rsc: { description: @@ -248,8 +249,15 @@ async function generateSchema(components: string[]): Promise { type: "boolean", default: true, }, + version: { + description: + "The version of Tailwind CSS to use. \nSee https://flowbite-react.com/docs/customize/config#version for more details.", + type: "number", + enum: [3, 4], + default: 4, + }, }, - required: ["components", "dark", "path", "prefix", "rsc", "tsx"], + required: ["components", "dark", "path", "prefix", "rsc", "tsx", "version"], }; defaultSchema.properties.components.items.enum.push(...components); diff --git a/packages/ui/src/cli/commands/build.ts b/packages/ui/src/cli/commands/build.ts index 35916958a0..e15a88be52 100644 --- a/packages/ui/src/cli/commands/build.ts +++ b/packages/ui/src/cli/commands/build.ts @@ -1,7 +1,49 @@ -import { generateClassList } from "./generate-class-list"; +import fs from "fs/promises"; +import { allowedExtensions, automaticClassGenerationMessage, classListFilePath, excludeDirs } from "../consts"; +import { buildClassList } from "../utils/build-class-list"; +import { extractComponentImports } from "../utils/extract-component-imports"; +import { findFiles } from "../utils/find-files"; +import { getConfig } from "../utils/get-config"; +import { setupInit } from "./setup-init"; import { setupOutputDirectory } from "./setup-output-directory"; export async function build() { await setupOutputDirectory(); - await generateClassList(); + + try { + const config = await getConfig(); + await setupInit(config); + + const importedComponents: string[] = []; + + if (config.components.length) { + console.warn(automaticClassGenerationMessage); + } else { + const files = await findFiles({ + patterns: allowedExtensions.map((ext) => `**/*${ext}`), + excludeDirs, + }); + + for (const file of files) { + const content = await fs.readFile(file, "utf-8"); + const components = extractComponentImports(content); + + if (components.length) { + importedComponents.push(...components); + } + } + } + + const classList = buildClassList({ + components: config.components.length ? config.components : [...new Set(importedComponents)], + dark: config.dark, + prefix: config.prefix, + version: config.version, + }); + + console.log(`Generating ${classListFilePath} file...`); + await fs.writeFile(classListFilePath, JSON.stringify(classList, null, 2)); + } catch (error) { + console.error(`Failed to generate ${classListFilePath}:`, error); + } } diff --git a/packages/ui/src/cli/commands/create.ts b/packages/ui/src/cli/commands/create.ts index 482ed2c833..fffaa2b52a 100644 --- a/packages/ui/src/cli/commands/create.ts +++ b/packages/ui/src/cli/commands/create.ts @@ -2,10 +2,12 @@ import fs from "fs/promises"; import path from "path"; import readline from "readline"; import { getConfig } from "../utils/get-config"; +import { setupInit } from "./setup-init"; export async function create(componentName?: string) { try { const config = await getConfig(); + await setupInit(config); let finalComponentName = componentName; @@ -163,7 +165,7 @@ ${formattedName}.displayName = "${formattedName}";`; // Write the component file console.log(`Creating component file at ${componentFilePath}...`); - await fs.writeFile(componentFilePath, componentContent, { flag: "w" }); + await fs.writeFile(componentFilePath, componentContent); console.log(`\n✅ Component ${formattedName} created successfully!`); } catch (error) { diff --git a/packages/ui/src/cli/commands/dev.ts b/packages/ui/src/cli/commands/dev.ts index 714ff8e274..d93763ad87 100644 --- a/packages/ui/src/cli/commands/dev.ts +++ b/packages/ui/src/cli/commands/dev.ts @@ -8,14 +8,23 @@ import { classListFilePath, configFilePath, excludeDirs, + gitIgnoreFilePath, + initFilePath, + initJsxFilePath, } from "../consts"; import { buildClassList } from "../utils/build-class-list"; import { extractComponentImports } from "../utils/extract-component-imports"; +import { findFiles } from "../utils/find-files"; import { getClassList } from "../utils/get-class-list"; import { getConfig } from "../utils/get-config"; +import { setupGitIgnore } from "./setup-gitignore"; +import { setupInit } from "./setup-init"; +import { setupOutputDirectory } from "./setup-output-directory"; export async function dev() { - const config = await getConfig(); + await setupOutputDirectory(); + let config = await getConfig(); + await setupInit(config); if (config.components.length) { console.warn(automaticClassGenerationMessage); @@ -24,6 +33,35 @@ export async function dev() { const importedComponentsMap: Record = {}; let classList = await getClassList(); + // initial run + const files = await findFiles({ + patterns: allowedExtensions.map((ext) => `**/*${ext}`), + excludeDirs, + }); + + for (const file of files) { + const content = await fs.readFile(file, "utf-8"); + const componentImports = extractComponentImports(content); + + if (componentImports.length) { + importedComponentsMap[file] = componentImports; + } + } + + const newImportedComponents = [...new Set(Object.values(importedComponentsMap).flat())]; + const newClassList = buildClassList({ + components: config.components.length ? config.components : newImportedComponents, + dark: config.dark, + prefix: config.prefix, + version: config.version, + }); + + if (!isEqual(classList, newClassList)) { + classList = newClassList; + await fs.writeFile(classListFilePath, JSON.stringify(classList, null, 2)); + } + + // watch for changes async function handleChange(path: string, eventName: "change" | "unlink") { if (eventName === "change") { const content = await fs.readFile(path, "utf-8"); @@ -41,16 +79,24 @@ export async function dev() { const newImportedComponents = [...new Set(Object.values(importedComponentsMap).flat())]; - const config = await getConfig(); + if ([configFilePath, initFilePath, initJsxFilePath].includes(path)) { + config = await getConfig(); + await setupInit(config); + } + if (path === gitIgnoreFilePath) { + await setupGitIgnore(); + } + const newClassList = buildClassList({ components: config.components.length ? config.components : newImportedComponents, dark: config.dark, prefix: config.prefix, + version: config.version, }); if (!isEqual(classList, newClassList)) { classList = newClassList; - await fs.writeFile(classListFilePath, JSON.stringify(classList, null, 2), { flag: "w" }); + await fs.writeFile(classListFilePath, JSON.stringify(classList, null, 2)); } } @@ -60,10 +106,13 @@ export async function dev() { return excludeDirs.includes(basename(path)); } if (stats?.isFile()) { - return !allowedExtensions.concat(configFilePath).some((ext) => path.endsWith(ext)); + return !allowedExtensions + .concat(configFilePath, gitIgnoreFilePath, initFilePath, initJsxFilePath) + .some((ext) => path.endsWith(ext)); } return false; }, + ignoreInitial: true, }); watcher.on("add", (path) => handleChange(path, "change")); diff --git a/packages/ui/src/cli/commands/ensure-tailwind.ts b/packages/ui/src/cli/commands/ensure-tailwind.ts index 28537837b0..f5267e6996 100644 --- a/packages/ui/src/cli/commands/ensure-tailwind.ts +++ b/packages/ui/src/cli/commands/ensure-tailwind.ts @@ -1,10 +1,13 @@ import { getPackageJson } from "../utils/get-package-json"; +/** + * Requires Tailwind CSS to be installed in the project. + */ export async function ensureTailwind() { const packageJson = await getPackageJson(); - const tailwindVersion = packageJson?.dependencies?.["tailwindcss"] || packageJson?.devDependencies?.["tailwindcss"]; + const packageName = "tailwindcss"; - if (!tailwindVersion) { + if (!(packageJson?.dependencies?.[packageName] || packageJson?.devDependencies?.[packageName])) { console.error("Install Tailwind CSS first.\n\nSee: https://tailwindcss.com/docs/installation"); process.exit(1); } diff --git a/packages/ui/src/cli/commands/generate-class-list.ts b/packages/ui/src/cli/commands/generate-class-list.ts deleted file mode 100644 index 8318f8a4c9..0000000000 --- a/packages/ui/src/cli/commands/generate-class-list.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from "fs/promises"; -import { allowedExtensions, classListFilePath, excludeDirs } from "../consts"; -import { buildClassList } from "../utils/build-class-list"; -import { extractComponentImports } from "../utils/extract-component-imports"; -import { findFiles } from "../utils/find-files"; -import { getConfig } from "../utils/get-config"; - -export async function generateClassList() { - try { - const config = await getConfig(); - - if (!config.components.length) { - const files = await findFiles({ - patterns: allowedExtensions.map((ext) => `**/*${ext}`), - excludeDirs, - }); - const importedComponents = new Set(); - - for (const file of files) { - const content = await fs.readFile(file, "utf-8"); - - for (const component of extractComponentImports(content)) { - importedComponents.add(component); - } - } - - if (importedComponents.size > 0) { - config.components = [...importedComponents]; - } - } - - const classList = buildClassList(config); - - console.log(`Generating ${classListFilePath} file...`); - await fs.writeFile(classListFilePath, JSON.stringify(classList, null, 2), { flag: "w" }); - } catch (error) { - console.error(`Failed to generate ${classListFilePath}:`, error); - } -} diff --git a/packages/ui/src/cli/commands/help.ts b/packages/ui/src/cli/commands/help.ts index 538b4d8a1d..f370b3c024 100644 --- a/packages/ui/src/cli/commands/help.ts +++ b/packages/ui/src/cli/commands/help.ts @@ -10,7 +10,6 @@ Commands: init Initialize Flowbite React with config files and necessary setup install Install Flowbite React using your detected package manager migrate Run code transformations to update to latest patterns and APIs - patch Patch Tailwind CSS to expose version number for compatibility register Register Flowbite React process for development with automatic class generation setup Setup additional features and configurations diff --git a/packages/ui/src/cli/commands/init.ts b/packages/ui/src/cli/commands/init.ts index 02d5ee5486..2227c1a29e 100644 --- a/packages/ui/src/cli/commands/init.ts +++ b/packages/ui/src/cli/commands/init.ts @@ -1,11 +1,10 @@ import { ensureTailwind } from "./ensure-tailwind"; -import { installFlowbiteReact } from "./install"; -import { patchTailwind } from "./patch"; +import { installPackage } from "./install"; import { setupClassList } from "./setup-class-list"; import { setupConfig } from "./setup-config"; import { setupGitIgnore } from "./setup-gitignore"; +import { setupInit } from "./setup-init"; import { setupOutputDirectory } from "./setup-output-directory"; -import { setupPatch } from "./setup-patch"; import { setupPlugin } from "./setup-plugin"; import { setupRegister } from "./setup-register"; import { setupTailwind } from "./setup-tailwind"; @@ -13,44 +12,19 @@ import { setupVSCode } from "./setup-vscode"; export async function init() { try { - // require `tailwindcss` await ensureTailwind(); - - // patch `tailwindcss` - await patchTailwind(); - - // install `flowbite-react` - await installFlowbiteReact(); - - // setup patch script in `package.json` - await setupPatch(); - - // setup `tailwindcss` - await setupTailwind(); - - // setup `.flowbite-react` directory + await installPackage(); await setupOutputDirectory(); - - // setup `.flowbite-react/class-list.json` file - await setupClassList(); - - // setup `.flowbite-react/config.json` file - await setupConfig(); - - // setup `.flowbite-react/.gitignore` file await setupGitIgnore(); - - // setup VSCode intellisense + await setupClassList(); + const config = await setupConfig(); + await setupInit(config); await setupVSCode(); - - // setup plugin based on bundler + await setupTailwind(); const hasBundler = await setupPlugin(); - if (!hasBundler) { - // setup register script in `package.json` await setupRegister(); } - console.log("\n✅ Flowbite React has been successfully initialized!"); } catch (error) { console.error("An error occurred during initialization:", error); diff --git a/packages/ui/src/cli/commands/install.ts b/packages/ui/src/cli/commands/install.ts index d8a39574c8..61539bca8c 100644 --- a/packages/ui/src/cli/commands/install.ts +++ b/packages/ui/src/cli/commands/install.ts @@ -1,16 +1,16 @@ import { resolveCommand } from "package-manager-detector/commands"; import { detect } from "package-manager-detector/detect"; import { execCommand } from "../utils/exec-command"; +import { getModulePackageJson } from "../utils/get-module-package-json"; import { getPackageJson } from "../utils/get-package-json"; -export async function installFlowbiteReact() { - try { - const packageJson = await getPackageJson(); - - if (packageJson.dependencies?.["flowbite-react"] || packageJson.devDependencies?.["flowbite-react"]) { - return; - } +/** + * Installs `flowbite-react` package using the detected package manager. + */ +export async function installPackage() { + const packageName = "flowbite-react"; + try { let pm = await detect(); if (!pm) { @@ -19,12 +19,26 @@ export async function installFlowbiteReact() { pm ??= { agent: "npm", name: "npm" }; - const packageName = "flowbite-react"; + const packageJson = await getPackageJson(); + const currentPackage = await getModulePackageJson(packageName); + + if (currentPackage && (packageJson?.dependencies?.[packageName] || packageJson?.devDependencies?.[packageName])) { + if (currentPackage.version.localeCompare("0.11", undefined, { numeric: true }) < 0) { + console.log( + "The current version of flowbite-react is below 0.11.x, which is the version with the new engine and CLI.", + ); + const { command = "", args } = resolveCommand(pm.agent, "add", [`${packageName}@latest`]) ?? {}; + console.log(`Updating ${packageName} to latest version using ${pm.name}...`); + await execCommand(command, args); + } + return; + } + const { command = "", args } = resolveCommand(pm.agent, "add", [packageName]) ?? {}; console.log(`Installing ${packageName} using ${pm.name}...`); - execCommand(command, args); + await execCommand(command, args); } catch (error) { - console.error("Failed to install flowbite-react:", error); + console.error(`Failed to install ${packageName}:`, error); } } diff --git a/packages/ui/src/cli/commands/patch.ts b/packages/ui/src/cli/commands/patch.ts deleted file mode 100644 index e2945048b4..0000000000 --- a/packages/ui/src/cli/commands/patch.ts +++ /dev/null @@ -1,151 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; - -/** - * Patches Tailwind CSS installation to ensure version files exist and are correctly configured. - * - * This function: - * - Resolves the Tailwind CSS module path - * - Reads the Tailwind package.json to get the actual version - * - Creates or updates version files (version.js, version.mjs, version.d.ts, version.d.mts) - * - Updates package.json exports if necessary - * - * @returns {Promise} A promise that resolves when patching is complete - */ -export async function patchTailwind(): Promise { - try { - let tailwindPath: string | undefined; - - try { - let tailwindModulePath; - if (typeof require !== "undefined") { - tailwindModulePath = require.resolve("tailwindcss/package.json", { - paths: [process.cwd()], - }); - tailwindPath = path.resolve(path.dirname(tailwindModulePath)); - } else { - const { createRequire } = await import("module"); - const require = createRequire(import.meta.url); - tailwindModulePath = require.resolve("tailwindcss/package.json", { - paths: [process.cwd()], - }); - tailwindPath = path.resolve(path.dirname(tailwindModulePath)); - } - } catch { - console.warn("Could not resolve Tailwind CSS module path. Skipping version patch."); - return; - } - - const tailwindPackageJsonPath = path.join(tailwindPath, "package.json"); - let tailwindPackageJson: { - version: string; - exports?: Record; - }; - - try { - const packageJsonContent = await fs.readFile(tailwindPackageJsonPath, "utf-8"); - tailwindPackageJson = JSON.parse(packageJsonContent); - } catch { - console.warn("Could not read Tailwind CSS `package.json`. Skipping version patch."); - return; - } - - const actualVersion = tailwindPackageJson.version; - - // Check if version files exist and have the correct version - const versionFilePath = path.join(tailwindPath, "version.js"); - const versionMjsFilePath = path.join(tailwindPath, "version.mjs"); - const versionDtsFilePath = path.join(tailwindPath, "version.d.ts"); - const versionDmtsFilePath = path.join(tailwindPath, "version.d.mts"); - - let patchesApplied = false; - - // create `version.js`, `version.mjs`, `version.d.ts` and `version.d.mts` files if needed - try { - let filesCreated = false; - - // Check and create `version.js` file (CJS) - if (await shouldUpdateFile(versionFilePath, actualVersion)) { - const versionContent = `"use strict";\n\nconst version = "${actualVersion}";\nmodule.exports = version;\n`; - await fs.writeFile(versionFilePath, versionContent, "utf-8"); - filesCreated = true; - } - - // Check and create `version.mjs` file (ESM) - if (await shouldUpdateFile(versionMjsFilePath, actualVersion)) { - const versionMjsContent = `const version = "${actualVersion}";\nexport default version;\n`; - await fs.writeFile(versionMjsFilePath, versionMjsContent, "utf-8"); - filesCreated = true; - } - - // Check and create `version.d.ts` file - if (await shouldUpdateFile(versionDtsFilePath)) { - const versionDtsContent = `declare const version: string;\nexport = version;\n`; - await fs.writeFile(versionDtsFilePath, versionDtsContent, "utf-8"); - filesCreated = true; - } - - // Check and create `version.d.mts` file - if (await shouldUpdateFile(versionDmtsFilePath)) { - const versionDmtsContent = `declare const version: string;\nexport default version;\n`; - await fs.writeFile(versionDmtsFilePath, versionDmtsContent, "utf-8"); - filesCreated = true; - } - - if (filesCreated) { - patchesApplied = true; - } - } catch { - console.warn("Could not create Tailwind CSS version files. Skipping version patch."); - } - - // patch package.json.exports - try { - if (tailwindPackageJson.exports) { - if (!tailwindPackageJson.exports["./version"] || !tailwindPackageJson.exports["./version.js"]) { - tailwindPackageJson.exports = { - ...tailwindPackageJson.exports, - "./version": { - require: "./version.js", - import: "./version.mjs", - }, - "./version.js": { - require: "./version.js", - import: "./version.mjs", - }, - }; - await fs.writeFile(tailwindPackageJsonPath, JSON.stringify(tailwindPackageJson, null, 2), "utf-8"); - patchesApplied = true; - } - } - } catch { - console.warn("Could not patch Tailwind CSS `package.json.exports`. Skipping version patch."); - } - - if (patchesApplied) { - console.log("Patched Tailwind CSS"); - } - } catch (error) { - console.error("Failed to patch Tailwind CSS:", error); - } -} - -/** - * Determines whether a file should be updated based on its existence and content. - * - * @param filePath - The path to the file to check - * @param actualVersion - Optional version string to check for in the file content - * @returns {Promise} True if the file doesn't exist or doesn't contain the actual version - */ -async function shouldUpdateFile(filePath: string, actualVersion?: string): Promise { - try { - const content = await fs.readFile(filePath, "utf-8"); - if (actualVersion !== undefined) { - return !content.includes(actualVersion); - } - return false; - } catch { - // File doesn't exist - return true; - } -} diff --git a/packages/ui/src/cli/commands/register.ts b/packages/ui/src/cli/commands/register.ts index 67ca0b4965..db9bc72269 100644 --- a/packages/ui/src/cli/commands/register.ts +++ b/packages/ui/src/cli/commands/register.ts @@ -3,10 +3,13 @@ import fs from "fs/promises"; import path from "path"; import { automaticClassGenerationMessage, outputDir, processIdFile } from "../consts"; import { getConfig } from "../utils/get-config"; +import { setupInit } from "./setup-init"; import { setupOutputDirectory } from "./setup-output-directory"; export async function register() { + await setupOutputDirectory(); const config = await getConfig(); + await setupInit(config); if (config.components.length) { console.warn(automaticClassGenerationMessage); @@ -31,10 +34,8 @@ export async function register() { shell: true, }); - await setupOutputDirectory(); - if (devProcess.pid) { - await fs.writeFile(processIdFilePath, devProcess.pid.toString(), { flag: "w" }); + await fs.writeFile(processIdFilePath, devProcess.pid.toString()); } } catch (error) { console.error("Failed to register flowbite-react", error); diff --git a/packages/ui/src/cli/commands/setup-class-list.ts b/packages/ui/src/cli/commands/setup-class-list.ts index b77448320d..a2ac66b76d 100644 --- a/packages/ui/src/cli/commands/setup-class-list.ts +++ b/packages/ui/src/cli/commands/setup-class-list.ts @@ -1,11 +1,16 @@ import fs from "fs/promises"; import { classListFilePath } from "../consts"; +/** + * Sets up the `.flowbite-react/class-list.json` file in the project. + * + * This function checks if the `.flowbite-react/class-list.json` file exists and creates it if it does not. + */ export async function setupClassList() { try { await fs.access(classListFilePath); } catch { console.log(`Creating ${classListFilePath} file...`); - await fs.writeFile(classListFilePath, JSON.stringify([], null, 2), { flag: "w" }); + await fs.writeFile(classListFilePath, JSON.stringify([], null, 2)); } } diff --git a/packages/ui/src/cli/commands/setup-config.ts b/packages/ui/src/cli/commands/setup-config.ts index 7e40e18d0f..b4572e89d3 100644 --- a/packages/ui/src/cli/commands/setup-config.ts +++ b/packages/ui/src/cli/commands/setup-config.ts @@ -1,21 +1,129 @@ import fs from "fs/promises"; +import { klona } from "klona/json"; +import { isEqual } from "../../helpers/is-equal"; +import { COMPONENT_TO_CLASS_LIST_MAP } from "../../metadata/class-list"; import { configFilePath } from "../consts"; -import type { Config } from "../utils/get-config"; +import { getTailwindVersion } from "../utils/get-tailwind-version"; + +export interface Config { + $schema: string; + components: string[]; + dark: boolean; + path: string; + prefix: string; + rsc: boolean; + tsx: boolean; + version: 3 | 4; +} + +/** + * Sets up the `.flowbite-react/config.json` file in the project. + * + * This function creates or updates the configuration file with default values and validates existing configurations. + */ +export async function setupConfig(): Promise { + const defaultConfig: Config = { + $schema: "https://unpkg.com/flowbite-react/schema.json", + components: [], + dark: true, + path: "src/components", + // TODO: infer from project + prefix: "", + rsc: true, + tsx: true, + version: await getTailwindVersion(), + }; + const writeTimeout = 10; -export async function setupConfig() { try { - await fs.access(configFilePath); - } catch { - const defaultConfig: Config = { - $schema: "https://unpkg.com/flowbite-react/schema.json", - components: [], - dark: true, - prefix: "", - path: "src/components", - tsx: true, - rsc: true, - }; - console.log(`Creating ${configFilePath} file...`); - await fs.writeFile(configFilePath, JSON.stringify(defaultConfig, null, 2), { flag: "w" }); + const raw = await fs.readFile(configFilePath, "utf-8"); + const config: Config = JSON.parse(raw); + let newConfig = klona(config); + + if (newConfig.$schema !== defaultConfig.$schema) { + console.warn(`Invalid $schema in ${configFilePath}: ${newConfig.$schema}`); + newConfig.$schema = defaultConfig.$schema; + } + if (!Array.isArray(newConfig.components)) { + console.warn(`Invalid components in ${configFilePath}: ${newConfig.components}`); + newConfig.components = [...defaultConfig.components]; + } else { + const isValidComponent = (component: unknown) => + typeof component === "string" && + component.trim() !== "" && + (component === "*" || + COMPONENT_TO_CLASS_LIST_MAP[component as keyof typeof COMPONENT_TO_CLASS_LIST_MAP] !== undefined); + + if (newConfig.components.some((component) => !isValidComponent(component))) { + console.warn(`Invalid components in ${configFilePath}: ${newConfig.components}`); + newConfig.components = newConfig.components.filter(isValidComponent); + } + } + if (typeof newConfig.dark !== "boolean") { + console.warn(`Invalid dark in ${configFilePath}: ${newConfig.dark}`); + newConfig.dark = defaultConfig.dark; + } + if (typeof newConfig.path !== "string") { + console.warn(`Invalid path in ${configFilePath}: ${newConfig.path}`); + newConfig.path = defaultConfig.path; + } + if (typeof newConfig.prefix !== "string") { + console.warn(`Invalid prefix in ${configFilePath}: ${newConfig.prefix}`); + newConfig.prefix = defaultConfig.prefix; + } + if (typeof newConfig.rsc !== "boolean") { + console.warn(`Invalid rsc in ${configFilePath}: ${newConfig.rsc}`); + newConfig.rsc = defaultConfig.rsc; + } + if (typeof newConfig.tsx !== "boolean") { + console.warn(`Invalid tsx in ${configFilePath}: ${newConfig.tsx}`); + newConfig.tsx = defaultConfig.tsx; + } + if (newConfig.version !== defaultConfig.version) { + console.warn(`Invalid version in ${configFilePath}: ${newConfig.version} (detected: ${defaultConfig.version})`); + newConfig.version = defaultConfig.version; + } + + for (const key in newConfig) { + if (!(key in defaultConfig)) { + console.warn(`Invalid property in ${configFilePath}: ${key}`); + delete newConfig[key as keyof Config]; + } + } + + const isSorted = isEqual(Object.keys(newConfig).sort(), Object.keys(newConfig)); + if (!isSorted) { + console.warn(`Invalid keys order in ${configFilePath}`); + newConfig = Object.fromEntries(Object.entries(newConfig).sort()) as Config; + } + + if (!isEqual(config, newConfig) || !isSorted) { + console.log(`Updating ${configFilePath} file...`); + setTimeout(() => fs.writeFile(configFilePath, JSON.stringify(newConfig, null, 2)), writeTimeout); + } + + if ( + newConfig.dark !== defaultConfig.dark || + newConfig.prefix !== defaultConfig.prefix || + newConfig.version !== defaultConfig.version + ) { + // TODO: search for in the project and warn if it's not found + console.info( + `\n[!] Custom values detected in ${configFilePath}, render at root level of your app to sync runtime with node config values.`, + `\n[!] Otherwise, your app will use the default values instead of your custom configuration.`, + `\n[!] Example: In case of custom 'prefix' or 'version', the app will not display the correct class names.`, + ); + } + + return newConfig; + } catch (error) { + if (error instanceof Error && error.message.includes("ENOENT")) { + console.log(`Creating ${configFilePath} file...`); + } else { + console.error(`Invalid ${configFilePath} file, regenerating...`); + } + + setTimeout(() => fs.writeFile(configFilePath, JSON.stringify(defaultConfig, null, 2)), writeTimeout); + return defaultConfig; } } diff --git a/packages/ui/src/cli/commands/setup-gitignore.ts b/packages/ui/src/cli/commands/setup-gitignore.ts index 237b1f8fe9..9dd0ba092f 100644 --- a/packages/ui/src/cli/commands/setup-gitignore.ts +++ b/packages/ui/src/cli/commands/setup-gitignore.ts @@ -1,19 +1,26 @@ import fs from "fs/promises"; -import path from "path"; -import { classListFile, outputDir, processIdFile } from "../consts"; +import { classListFile, gitIgnoreFilePath, processIdFile } from "../consts"; +/** + * Sets up the `.flowbite-react/.gitignore` file in the project. + * + * This function ensures the `.gitignore` file exists in the `.flowbite-react` directory. + * It will create or update the file if needed. + */ export async function setupGitIgnore() { - const gitIgnoreFilePath = path.join(outputDir, ".gitignore"); + const content = `${classListFile}\n${processIdFile}`; try { - const gitignore = await fs.readFile(gitIgnoreFilePath, "utf-8").catch(() => { - console.log(`Creating ${gitIgnoreFilePath} file...`); - return ""; - }); + let currentContent: string; + try { + currentContent = await fs.readFile(gitIgnoreFilePath, "utf-8"); + } catch { + currentContent = ""; + } - if (![classListFile, processIdFile].some((file) => gitignore.includes(file))) { - console.log(`Adding ${classListFile}, ${processIdFile} to ${gitIgnoreFilePath}...`); - await fs.writeFile(gitIgnoreFilePath, `${classListFile}\n${processIdFile}`, { flag: "w" }); + if (currentContent.trimEnd() !== content) { + console.log(`${currentContent ? "Updating" : "Creating"} ${gitIgnoreFilePath} file...`); + setTimeout(() => fs.writeFile(gitIgnoreFilePath, content), 10); } } catch (error) { console.error(`Failed to update ${gitIgnoreFilePath}:`, error); diff --git a/packages/ui/src/cli/commands/setup-init.ts b/packages/ui/src/cli/commands/setup-init.ts new file mode 100644 index 0000000000..f42370babe --- /dev/null +++ b/packages/ui/src/cli/commands/setup-init.ts @@ -0,0 +1,104 @@ +import fs from "fs/promises"; +import { parse } from "recast"; +import { initFilePath, initJsxFilePath } from "../consts"; +import type { Config } from "./setup-config"; + +/** + * Sets up the `.flowbite-react/init.tsx` file in the project. + * + * This function ensures the init.tsx file exists and is up-to-date with the current configuration. + * It will create or update the file if needed. + */ +export async function setupInit(config: Config) { + const content = ` +/* eslint-disable */ +// @ts-nocheck +// biome-ignore-all lint: auto-generated file + +// This file is auto-generated by the flowbite-react CLI. +// Do not edit this file directly. +// Instead, edit the .flowbite-react/config.json file. + +import { StoreInit } from "flowbite-react/store/init"; +import React from "react"; + +export const config = { + dark: ${config.dark}, + prefix: "${config.prefix}", + version: ${config.version}, +}; + +export function ThemeInit() { + return ; +} + +ThemeInit.displayName = "ThemeInit"; +`.trim(); + + const targetPath = config.tsx ? initFilePath : initJsxFilePath; + const oldPath = config.tsx ? initJsxFilePath : initFilePath; + + try { + let currentContent = ""; + try { + currentContent = await fs.readFile(targetPath, "utf-8"); + } catch { + console.log(`Creating ${targetPath} file...`); + setTimeout(() => fs.writeFile(targetPath, content), 10); + } + + if (currentContent) { + const currentAst = parse(currentContent); + const newAst = parse(content); + + if (!compareNodes(currentAst.program, newAst.program)) { + console.log(`Updating ${targetPath} file...`); + setTimeout(() => fs.writeFile(targetPath, content), 10); + } + } + + try { + await fs.access(oldPath); + console.log(`Removing ${oldPath} file...`); + await fs.unlink(oldPath); + } catch { + // noop + } + } catch (error) { + console.error(`Failed to update ${targetPath}:`, error); + } +} + +/** + * Compare two AST nodes ignoring location info and comments + */ +function compareNodes(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((item, i) => compareNodes(item, b[i])); + } + if (typeof a !== "object" || typeof b !== "object") { + return a === b; + } + + // Skip location and comment-related properties + const keysA = Object.keys(a).filter( + (k) => !["start", "end", "loc", "range", "tokens", "comments", "leadingComments", "trailingComments"].includes(k), + ); + const keysB = Object.keys(b).filter( + (k) => !["start", "end", "loc", "range", "tokens", "comments", "leadingComments", "trailingComments"].includes(k), + ); + + if (keysA.length !== keysB.length) { + return false; + } + return keysA.every((key) => compareNodes(a[key as keyof typeof a], b[key as keyof typeof b])); +} diff --git a/packages/ui/src/cli/commands/setup-output-directory.ts b/packages/ui/src/cli/commands/setup-output-directory.ts index 6136d11eb2..1cb0803462 100644 --- a/packages/ui/src/cli/commands/setup-output-directory.ts +++ b/packages/ui/src/cli/commands/setup-output-directory.ts @@ -1,6 +1,11 @@ import fs from "fs/promises"; import { outputDir } from "../consts"; +/** + * Sets up the `.flowbite-react` directory in the project. + * + * This function checks if the `.flowbite-react` directory exists and creates it if it does not. + */ export async function setupOutputDirectory() { try { await fs.access(outputDir); diff --git a/packages/ui/src/cli/commands/setup-patch.ts b/packages/ui/src/cli/commands/setup-patch.ts deleted file mode 100644 index d13f08c39b..0000000000 --- a/packages/ui/src/cli/commands/setup-patch.ts +++ /dev/null @@ -1,27 +0,0 @@ -import fs from "fs/promises"; -import cjson from "comment-json"; -import { packageJsonFile } from "../consts"; -import { getPackageJson } from "../utils/get-package-json"; - -export async function setupPatch() { - try { - const patchCommand = "flowbite-react patch"; - const packageJson = await getPackageJson(); - - if (!packageJson.scripts) { - packageJson.scripts = {}; - } - - if (!packageJson.scripts.postinstall?.includes(patchCommand)) { - console.log(`Adding postinstall patch script to ${packageJsonFile}...`); - if (packageJson.scripts.postinstall) { - packageJson.scripts.postinstall += ` && ${patchCommand}`; - } else { - packageJson.scripts.postinstall = patchCommand; - } - await fs.writeFile(packageJsonFile, cjson.stringify(packageJson, null, 2), { flag: "w" }); - } - } catch (error) { - console.error(`Failed to setup ${packageJsonFile}:`, error); - } -} diff --git a/packages/ui/src/cli/commands/setup-plugin.ts b/packages/ui/src/cli/commands/setup-plugin.ts index 2772777103..73cd8573b8 100644 --- a/packages/ui/src/cli/commands/setup-plugin.ts +++ b/packages/ui/src/cli/commands/setup-plugin.ts @@ -1,5 +1,11 @@ import fs from "fs/promises"; +/** + * Sets up the plugin for the project based on the bundler. + * + * This function checks for the existence of configuration files for various bundlers and frameworks + * and sets up the appropriate plugin for each. + */ export async function setupPlugin() { const configFileMap = { astro: ["astro.config.cjs", "astro.config.mjs", "astro.config.ts", "astro.config.js"], diff --git a/packages/ui/src/cli/commands/setup-register.ts b/packages/ui/src/cli/commands/setup-register.ts index 7afa569b8e..39782745cf 100644 --- a/packages/ui/src/cli/commands/setup-register.ts +++ b/packages/ui/src/cli/commands/setup-register.ts @@ -3,6 +3,13 @@ import cjson from "comment-json"; import { packageJsonFile } from "../consts"; import { getPackageJson } from "../utils/get-package-json"; +/** + * Sets up the register script in the project's package.json file. + * + * This function checks if the postinstall script already exists in the package.json file. + * If it does not exist, it adds the register command to the postinstall script. + * If it does exist, it appends the register command to the existing postinstall script. + */ export async function setupRegister() { try { const registerCommand = "flowbite-react register"; @@ -19,7 +26,7 @@ export async function setupRegister() { } else { packageJson.scripts.postinstall = registerCommand; } - await fs.writeFile(packageJsonFile, cjson.stringify(packageJson, null, 2), { flag: "w" }); + await fs.writeFile(packageJsonFile, cjson.stringify(packageJson, null, 2)); } } catch (error) { console.error(`Failed to setup ${packageJsonFile}:`, error); diff --git a/packages/ui/src/cli/commands/setup-tailwind.ts b/packages/ui/src/cli/commands/setup-tailwind.ts index 09acd52c50..05dca47b85 100644 --- a/packages/ui/src/cli/commands/setup-tailwind.ts +++ b/packages/ui/src/cli/commands/setup-tailwind.ts @@ -6,6 +6,12 @@ import { addToConfig } from "../utils/add-to-config"; import { findFiles } from "../utils/find-files"; import { joinNormalizedPath } from "../utils/normalize-path"; +/** + * Sets up Tailwind CSS configuration for the project. + * + * This function checks if Tailwind CSS is installed in the project and then + * attempts to add the necessary configuration for Tailwind CSS v4 or v3. + */ export async function setupTailwind() { try { const found = !!((await setupTailwindV4()) || (await setupTailwindV3())); @@ -18,6 +24,12 @@ export async function setupTailwind() { } } +/** + * Sets up Tailwind CSS v4 configuration for the project. + * + * This function searches for Tailwind CSS files in the project and attempts to + * add the necessary configuration for Tailwind CSS v4. + */ async function setupTailwindV4() { try { const cssFiles = await findFiles({ diff --git a/packages/ui/src/cli/commands/setup-vscode.ts b/packages/ui/src/cli/commands/setup-vscode.ts index d8fe2d514f..fd7f7783c6 100644 --- a/packages/ui/src/cli/commands/setup-vscode.ts +++ b/packages/ui/src/cli/commands/setup-vscode.ts @@ -3,6 +3,12 @@ import path from "path"; import cjson from "comment-json"; import { vscodeDir } from "../consts"; +/** + * Sets up the VSCode configuration for the project. + * + * This function checks if the `.vscode` directory exists and creates it if it does not. + * It then sets up the `settings.json` and `extensions.json` files with the necessary configuration for Flowbite React. + */ export async function setupVSCode() { try { await fs.access(vscodeDir); @@ -15,6 +21,12 @@ export async function setupVSCode() { await setupVSCodeExtensions(); } +/** + * Sets up the VSCode settings for the project. + * + * This function checks if the `settings.json` file exists and creates it if it does not. + * It then sets up the `files.associations`, `tailwindCSS.classAttributes`, and `tailwindCSS.experimental.classRegex` settings. + */ async function setupVSCodeSettings() { try { const vscodeSettingsFilePath = path.join(vscodeDir, "settings.json"); @@ -86,7 +98,7 @@ async function setupVSCodeSettings() { } console.log(`${exists ? "Updating" : "Creating"} ${vscodeSettingsFilePath} with flowbite-react configuration...`); - await fs.writeFile(vscodeSettingsFilePath, cjson.stringify(settings, null, 2), { flag: "w" }); + await fs.writeFile(vscodeSettingsFilePath, cjson.stringify(settings, null, 2)); } catch (error) { console.error("Failed to setup VSCode settings:", error); } @@ -134,7 +146,7 @@ async function setupVSCodeExtensions() { } console.log(`${exists ? "Updating" : "Creating"} ${vscodeExtensionsFilePath} with flowbite-react configuration...`); - await fs.writeFile(vscodeExtensionsFilePath, cjson.stringify(extensions, null, 2), { flag: "w" }); + await fs.writeFile(vscodeExtensionsFilePath, cjson.stringify(extensions, null, 2)); } catch (error) { console.error("Failed to setup VSCode extensions:", error); } diff --git a/packages/ui/src/cli/consts.ts b/packages/ui/src/cli/consts.ts index 78f09af906..fd5e29900d 100644 --- a/packages/ui/src/cli/consts.ts +++ b/packages/ui/src/cli/consts.ts @@ -1,16 +1,21 @@ import path from "path"; -export const pluginName = "flowbiteReact"; -export const pluginPath = "flowbite-react/plugin"; export const classListFile = "class-list.json"; export const configFile = "config.json"; +export const gitIgnoreFile = ".gitignore"; +export const initFile = "init"; export const outputDir = ".flowbite-react"; export const packageJsonFile = "package.json"; +export const pluginName = "flowbiteReact"; +export const pluginPath = "flowbite-react/plugin"; export const processIdFile = "pid"; export const vscodeDir = ".vscode"; export const classListFilePath = path.join(outputDir, classListFile); export const configFilePath = path.join(outputDir, configFile); +export const gitIgnoreFilePath = path.join(outputDir, gitIgnoreFile); +export const initFilePath = path.join(outputDir, `${initFile}.tsx`); +export const initJsxFilePath = path.join(outputDir, `${initFile}.jsx`); export const excludeDirs = [ ".astro", diff --git a/packages/ui/src/cli/main.ts b/packages/ui/src/cli/main.ts index c30e361073..d4de29320a 100644 --- a/packages/ui/src/cli/main.ts +++ b/packages/ui/src/cli/main.ts @@ -23,17 +23,13 @@ export async function main(argv: string[]) { await init(); } if (command === "install") { - const { installFlowbiteReact } = await import("./commands/install"); - await installFlowbiteReact(); + const { installPackage } = await import("./commands/install"); + await installPackage(); } if (command === "migrate") { const { migrate } = await import("./commands/migrate"); await migrate(); } - if (command === "patch") { - const { patchTailwind } = await import("./commands/patch"); - await patchTailwind(); - } if (command === "register") { const { register } = await import("./commands/register"); await register(); @@ -54,19 +50,7 @@ export async function main(argv: string[]) { } if ( - ![ - "build", - "create", - "dev", - "help", - "--help", - "init", - "install", - "migrate", - "patch", - "register", - "setup", - ].includes(command) + !["build", "create", "dev", "help", "--help", "init", "install", "migrate", "register", "setup"].includes(command) ) { console.error(`Unknown command: ${command}`); const { help } = await import("./commands/help"); diff --git a/packages/ui/src/cli/utils/build-class-list.ts b/packages/ui/src/cli/utils/build-class-list.ts index fd597ccf37..52b001253b 100644 --- a/packages/ui/src/cli/utils/build-class-list.ts +++ b/packages/ui/src/cli/utils/build-class-list.ts @@ -1,7 +1,6 @@ import { applyPrefix } from "../../helpers/apply-prefix"; import { applyPrefixV3 } from "../../helpers/apply-prefix-v3"; import { convertUtilitiesToV4 } from "../../helpers/convert-utilities-to-v4"; -import { getTailwindVersion } from "../../helpers/get-tailwind-version"; import { stripDark } from "../../helpers/strip-dark"; import { CLASS_LIST_MAP, COMPONENT_TO_CLASS_LIST_MAP } from "../../metadata/class-list"; import { DEPENDENCY_LIST_MAP } from "../../metadata/dependency-list"; @@ -13,19 +12,20 @@ import { DEPENDENCY_LIST_MAP } from "../../metadata/dependency-list"; * @param {string[]} options.components - The components to include in the class list. * @param {boolean} options.dark - Whether to include dark mode classes. * @param {string} options.prefix - The prefix to apply to the class names. + * @param {3 | 4} options.version - The version of Tailwind CSS to use. * @returns {string[]} The sorted list of CSS classes. */ export function buildClassList({ components, dark, prefix, + version, }: { components: string[]; dark: boolean; prefix: string; + version: 3 | 4; }): string[] { - const version = getTailwindVersion(); - let classList: string[] = []; if (components.includes("*")) { diff --git a/packages/ui/src/cli/utils/get-config.ts b/packages/ui/src/cli/utils/get-config.ts index 9959e26e86..3cff520517 100644 --- a/packages/ui/src/cli/utils/get-config.ts +++ b/packages/ui/src/cli/utils/get-config.ts @@ -1,63 +1,15 @@ -import fs from "fs/promises"; -import { configFilePath } from "../consts"; - -export interface Config { - $schema: string; - components: string[]; - dark: boolean; - path: string; - prefix: string; - rsc: boolean; - tsx: boolean; -} +import { setupConfig, type Config } from "../commands/setup-config"; /** - * Reads the configuration file and returns its content as a Config object. + * Gets the current configuration by reading and validating the config file. * - * This function attempts to read the file specified by `configFilePath`, parse its content as JSON, - * and return the parsed configuration. If the file cannot be read or parsed, a default Config object is returned. + * This function calls `setupConfig()` which will: + * - Create the config file if it doesn't exist + * - Validate and fix any invalid config values + * - Return the parsed configuration * - * @returns {Promise} A promise that resolves to a Config object representing the configuration. + * @returns {Promise} A promise that resolves to the validated configuration object */ export async function getConfig(): Promise { - const config: Config = { - $schema: "", - components: [], - dark: true, - path: "src/components", - prefix: "", - rsc: true, - tsx: true, - }; - - try { - const raw = await fs.readFile(configFilePath, "utf-8"); - const parsed: Config = JSON.parse(raw); - - if (parsed.$schema !== undefined && typeof parsed.$schema === "string") { - config.$schema = parsed.$schema; - } - if (parsed.components !== undefined && Array.isArray(parsed.components)) { - config.components = parsed.components.map((component) => component.trim()).filter(Boolean); - } - if (parsed.dark !== undefined && typeof parsed.dark === "boolean") { - config.dark = parsed.dark; - } - if (parsed.path !== undefined && typeof parsed.path === "string") { - config.path = parsed.path; - } - if (parsed.prefix !== undefined && typeof parsed.prefix === "string") { - config.prefix = parsed.prefix; - } - if (parsed.rsc !== undefined && typeof parsed.rsc === "boolean") { - config.rsc = parsed.rsc; - } - if (parsed.tsx !== undefined && typeof parsed.tsx === "boolean") { - config.tsx = parsed.tsx; - } - - return config; - } catch { - return config; - } + return await setupConfig(); } diff --git a/packages/ui/src/cli/utils/get-module-package-json.ts b/packages/ui/src/cli/utils/get-module-package-json.ts new file mode 100644 index 0000000000..45a28e913a --- /dev/null +++ b/packages/ui/src/cli/utils/get-module-package-json.ts @@ -0,0 +1,22 @@ +import { createRequire } from "module"; + +/** + * Gets the package.json contents for a given package. + * + * @param packageName - The name of the package to get the package.json from + * @returns The contents of the package.json file or null if the package is not installed + */ +export async function getModulePackageJson>( + packageName: string, +): Promise { + try { + const require = createRequire(import.meta.url); + return require(`${packageName}/package.json`); + } catch { + try { + return (await import(`${packageName}/package.json`)).default; + } catch { + return null; + } + } +} diff --git a/packages/ui/src/cli/utils/get-package-json.ts b/packages/ui/src/cli/utils/get-package-json.ts index 901dcc8720..b65087b194 100644 --- a/packages/ui/src/cli/utils/get-package-json.ts +++ b/packages/ui/src/cli/utils/get-package-json.ts @@ -5,9 +5,9 @@ import { packageJsonFile } from "../consts"; export interface PackageJson { name: string; version: string; + scripts: Record; dependencies: Record; devDependencies: Record; - scripts: Record; } /** * Reads and parses the package.json file. diff --git a/packages/ui/src/cli/utils/get-tailwind-version.ts b/packages/ui/src/cli/utils/get-tailwind-version.ts new file mode 100644 index 0000000000..f71075138a --- /dev/null +++ b/packages/ui/src/cli/utils/get-tailwind-version.ts @@ -0,0 +1,24 @@ +import { getModulePackageJson } from "./get-module-package-json"; + +/** + * Gets the version of Tailwind CSS used in the project. + * + * This function attempts to read the version from the `tailwindcss/package.json` file. + * + * @throws {Error} If the detected Tailwind CSS major version is not 3 or 4 + * @returns {Promise<3 | 4>} `3` if the version is 3.x, `4` if the version is 4.x + */ +export async function getTailwindVersion(): Promise<3 | 4> { + const tailwindcssPackageJson = await getModulePackageJson("tailwindcss"); + + if (!tailwindcssPackageJson) { + throw new Error("Tailwind CSS is not installed"); + } + + const major = parseInt(tailwindcssPackageJson.version.split(".")[0], 10); + if (major === 3 || major === 4) { + return major; + } + + throw new Error(`Unsupported Tailwind CSS major version: ${major}`); +} diff --git a/packages/ui/src/helpers/get-tailwind-version.ts b/packages/ui/src/helpers/get-tailwind-version.ts deleted file mode 100644 index 8b9a9a0074..0000000000 --- a/packages/ui/src/helpers/get-tailwind-version.ts +++ /dev/null @@ -1,14 +0,0 @@ -import version from "tailwindcss/version.js"; - -/** - * Gets the major version number of the installed Tailwind CSS - * - * @returns The major version number (3 or 4) or undefined if not found - */ -export function getTailwindVersion(): 3 | 4 | undefined { - try { - return parseInt(version.split(".")[0], 10) as 3 | 4; - } catch (_) { - return; - } -} diff --git a/packages/ui/src/helpers/resolve-theme.test.ts b/packages/ui/src/helpers/resolve-theme.test.ts index cc292cfb0e..262450aa61 100644 --- a/packages/ui/src/helpers/resolve-theme.test.ts +++ b/packages/ui/src/helpers/resolve-theme.test.ts @@ -18,7 +18,7 @@ describe("resolveTheme", () => { }); it("should apply prefix with version 3 format", () => { - setStore({ prefix: "tw-" }); + setStore({ prefix: "tw-", version: 3 }); const base = { color: "text-red-400" }; diff --git a/packages/ui/src/helpers/resolve-theme.ts b/packages/ui/src/helpers/resolve-theme.ts index e465a8c000..e8c06c4ab5 100644 --- a/packages/ui/src/helpers/resolve-theme.ts +++ b/packages/ui/src/helpers/resolve-theme.ts @@ -1,13 +1,12 @@ import { deepmerge } from "deepmerge-ts"; import { klona } from "klona/json"; import { useRef } from "react"; -import { getDark, getPrefix } from "../store"; +import { getDark, getPrefix, getVersion } from "../store"; import type { ApplyTheme, DeepPartialApplyTheme, DeepPartialBoolean } from "../types"; import { applyPrefix } from "./apply-prefix"; import { applyPrefixV3 } from "./apply-prefix-v3"; import { convertUtilitiesToV4 } from "./convert-utilities-to-v4"; import { deepMergeStrings } from "./deep-merge"; -import { getTailwindVersion } from "./get-tailwind-version"; import { isEqual } from "./is-equal"; import { stripDark } from "./strip-dark"; import { twMerge } from "./tailwind-merge"; @@ -67,7 +66,7 @@ export function resolveTheme( ): T { const dark = getDark(); const prefix = getPrefix(); - const version = getTailwindVersion(); + const version = getVersion(); const _custom = custom?.length ? custom?.filter((value) => value !== undefined) : undefined; const _clearThemeList = clearThemeList?.length ? clearThemeList?.filter((value) => value !== undefined) : undefined; diff --git a/packages/ui/src/helpers/tailwind-merge.ts b/packages/ui/src/helpers/tailwind-merge.ts index cbbe55fb5c..b45ca96a78 100644 --- a/packages/ui/src/helpers/tailwind-merge.ts +++ b/packages/ui/src/helpers/tailwind-merge.ts @@ -1,13 +1,12 @@ import { extendTailwindMerge as extendTailwindMerge_v2 } from "tailwind-merge-v2"; import { extendTailwindMerge as extendTailwindMerge_v3, type ClassNameValue } from "tailwind-merge-v3"; -import { getPrefix } from "../store"; -import { getTailwindVersion } from "./get-tailwind-version"; +import { getPrefix, getVersion } from "../store"; const cache = new Map>(); export function twMerge(...classLists: ClassNameValue[]): string { const prefix = getPrefix(); - const version = getTailwindVersion(); + const version = getVersion(); const cacheKey = `${prefix}.${version}`; const cacheValue = cache.get(cacheKey); diff --git a/packages/ui/src/hooks/use-theme-mode.ts b/packages/ui/src/hooks/use-theme-mode.ts index b1ba6035bd..40e9ab1caf 100644 --- a/packages/ui/src/hooks/use-theme-mode.ts +++ b/packages/ui/src/hooks/use-theme-mode.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { isClient } from "../helpers/is-client"; import { useWatchLocalStorageValue } from "../hooks/use-watch-localstorage-value"; -import { getMode, getPrefix } from "../store"; +import { getMode, getPrefix, getVersion } from "../store"; const DEFAULT_MODE: ThemeMode = "auto"; const LS_THEME_MODE = "flowbite-theme-mode"; @@ -98,13 +98,15 @@ function setModeInLS(mode: ThemeMode) { * Add or remove class `dark` on `html` element */ function setModeInDOM(mode: ThemeMode) { - const prefix = getPrefix() ?? ""; const computedMode = computeModeValue(mode); + const prefix = getPrefix() ?? ""; + const version = getVersion(); + const className = version === 3 ? `${prefix}dark` : "dark"; if (computedMode === "dark") { - document.documentElement.classList.add(`${prefix}dark`); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove(`${prefix}dark`); + document.documentElement.classList.remove(className); } } diff --git a/packages/ui/src/store/index.ts b/packages/ui/src/store/index.ts index a78235c7f2..948c26fa02 100644 --- a/packages/ui/src/store/index.ts +++ b/packages/ui/src/store/index.ts @@ -22,12 +22,19 @@ export type StoreProps = DeepPartial<{ * @default undefined */ prefix: string; + /** + * The version of Tailwind CSS to use + * + * @default 4 + */ + version: 3 | 4; }>; const store: StoreProps = { dark: undefined, mode: undefined, prefix: undefined, + version: undefined, }; export function setStore(data: StoreProps) { @@ -44,6 +51,13 @@ export function setStore(data: StoreProps) { if ("prefix" in data) { store.prefix = data.prefix; } + if ("version" in data) { + if (data.version === 3 || data.version === 4) { + store.version = data.version; + } else { + console.warn(`Invalid version value: ${data.version}.\nAvailable values: 3, 4`); + } + } } export function getDark(): StoreProps["dark"] { @@ -57,3 +71,7 @@ export function getMode(): StoreProps["mode"] { export function getPrefix(): StoreProps["prefix"] { return store.prefix; } + +export function getVersion(): StoreProps["version"] { + return store.version; +} diff --git a/packages/ui/src/theme/config.tsx b/packages/ui/src/theme/config.tsx index ce126fb0b3..5001a565c9 100644 --- a/packages/ui/src/theme/config.tsx +++ b/packages/ui/src/theme/config.tsx @@ -1,10 +1,10 @@ import type { StoreProps } from "../store"; import { StoreInit } from "../store/init"; -export type ThemeConfigProps = StoreProps; +export type ThemeConfigProps = Pick; -export function ThemeConfig(props: ThemeConfigProps) { - return ; +export function ThemeConfig({ mode }: ThemeConfigProps) { + return ; } ThemeConfig.displayName = "ThemeConfig"; diff --git a/packages/ui/src/theme/mode-script.test.tsx b/packages/ui/src/theme/mode-script.test.tsx index 0114f5e123..fbd3f3fa6a 100644 --- a/packages/ui/src/theme/mode-script.test.tsx +++ b/packages/ui/src/theme/mode-script.test.tsx @@ -1,7 +1,6 @@ import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { getPrefix } from "../store"; -import { ThemeModeScript } from "./mode-script"; +import { getThemeModeScript, ThemeModeScript } from "./mode-script"; describe("ThemeModeScript", () => { it("should render with default props", () => { @@ -34,11 +33,39 @@ describe("ThemeModeScript", () => { expect(script?.innerHTML).toContain("custom-key"); }); - it("should include prefix from getPrefix()", () => { - const prefix = getPrefix() ?? ""; - const { container } = render(); + it("should include prefix when version is 3", () => { + const { container } = render(); const script = container.querySelector("script"); - expect(script?.innerHTML).toContain(`"${prefix}dark"`); + expect(script?.innerHTML).toContain('const className = "custom-dark"'); + }); + + it("should not include prefix when version is 4", () => { + const { container } = render(); + const script = container.querySelector("script"); + + expect(script?.innerHTML).toContain('const className = "dark"'); + }); +}); + +describe("getThemeModeScript", () => { + it("should return the correct script for prefix and version 3", () => { + const script = getThemeModeScript({ prefix: "custom-", version: 3 }); + expect(script).toContain('const className = "custom-dark"'); + }); + + it("should return the correct script for prefix and version 4", () => { + const script = getThemeModeScript({ prefix: "custom-", version: 4 }); + expect(script).toContain('const className = "dark"'); + }); + + it("should return the correct script for version 3", () => { + const script = getThemeModeScript({ version: 3 }); + expect(script).toContain('const className = "dark"'); + }); + + it("should return the correct script for version 4", () => { + const script = getThemeModeScript({ version: 4 }); + expect(script).toContain('const className = "dark"'); }); }); diff --git a/packages/ui/src/theme/mode-script.tsx b/packages/ui/src/theme/mode-script.tsx index ce13b89034..95ee95b5fa 100644 --- a/packages/ui/src/theme/mode-script.tsx +++ b/packages/ui/src/theme/mode-script.tsx @@ -1,12 +1,12 @@ import type React from "react"; import type { ThemeMode } from "../hooks/use-theme-mode"; -import { getPrefix } from "../store"; const defaultOptions = { mode: "auto" as ThemeMode, defaultMode: "auto" as ThemeMode, localStorageKey: "flowbite-theme-mode", prefix: "", + version: 4 as 3 | 4, }; export interface ThemeModeScriptProps extends React.ComponentPropsWithoutRef<"script"> { @@ -30,6 +30,20 @@ export interface ThemeModeScriptProps extends React.ComponentPropsWithoutRef<"sc * @default "flowbite-theme-mode" */ localStorageKey?: string; + /** + * The prefix to use for the theme mode class + * + * @type {string} + * @default "" + */ + prefix?: string; + /** + * The version of Tailwind CSS to use + * + * @type {3 | 4} + * @default 4 + */ + version?: 3 | 4; } /** @@ -46,6 +60,8 @@ export function ThemeModeScript({ mode, defaultMode = defaultOptions.defaultMode, localStorageKey = defaultOptions.localStorageKey, + prefix = defaultOptions.prefix, + version = defaultOptions.version, ...others }: ThemeModeScriptProps): JSX.Element { return ( @@ -53,7 +69,13 @@ export function ThemeModeScript({ {...others} data-flowbite-theme-mode-script dangerouslySetInnerHTML={{ - __html: getThemeModeScript({ mode, defaultMode, localStorageKey, prefix: getPrefix() ?? "" }), + __html: getThemeModeScript({ + mode, + defaultMode, + localStorageKey, + prefix, + version, + }), }} /> ); @@ -77,6 +99,7 @@ export function getThemeModeScript( defaultMode?: ThemeMode; localStorageKey?: string; prefix?: string; + version?: 3 | 4; } = {}, ): string { const { @@ -84,6 +107,7 @@ export function getThemeModeScript( defaultMode = defaultOptions.defaultMode, localStorageKey = defaultOptions.localStorageKey, prefix = defaultOptions.prefix, + version = defaultOptions.version, } = props; return ` @@ -93,11 +117,12 @@ export function getThemeModeScript( const resolvedMode = (isStorageModeValid ? storageMode : null) ?? ${mode ? `"${mode}"` : undefined} ?? "${defaultMode}"; const computedMode = resolvedMode === "auto" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : resolvedMode; + const className = ${version === 3 ? `"${prefix}dark"` : `"dark"`}; if (computedMode === "dark") { - document.documentElement.classList.add("${prefix}dark"); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove("${prefix}dark"); + document.documentElement.classList.remove(className); } localStorage.setItem("${localStorageKey}", resolvedMode); @@ -110,9 +135,9 @@ export function getThemeModeScript( if (resolvedMode === "auto") { if (e.matches) { - document.documentElement.classList.add("${prefix}dark"); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove("${prefix}dark"); + document.documentElement.classList.remove(className); } } }); @@ -125,9 +150,9 @@ export function getThemeModeScript( const resolvedMode = isStorageModeValid ? newMode : "${defaultMode}"; if (resolvedMode === "dark" || (resolvedMode === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches)) { - document.documentElement.classList.add("${prefix}dark"); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove("${prefix}dark"); + document.documentElement.classList.remove(className); } } }); @@ -144,6 +169,7 @@ export function getThemeModeScript( * @param {ThemeMode} [options.defaultMode="auto"] - The default theme mode if none is set * @param {string} [options.localStorageKey="flowbite-theme-mode"] - Key used to store theme mode in localStorage * @param {string} [options.prefix=""] - The prefix to use for the theme mode class + * @param {3 | 4} [options.version=4] - The version of Tailwind CSS to use * @returns {void} */ export function initThemeMode( @@ -152,6 +178,7 @@ export function initThemeMode( defaultMode?: ThemeMode; localStorageKey?: string; prefix?: string; + version?: 3 | 4; } = {}, ): void { const { @@ -159,6 +186,7 @@ export function initThemeMode( defaultMode = defaultOptions.defaultMode, localStorageKey = defaultOptions.localStorageKey, prefix = defaultOptions.prefix, + version = defaultOptions.version, } = props; try { @@ -171,11 +199,12 @@ export function initThemeMode( ? "dark" : "light" : resolvedMode; + const className = version === 3 ? `${prefix}dark` : "dark"; if (computedMode === "dark") { - document.documentElement.classList.add(`${prefix}dark`); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove(`${prefix}dark`); + document.documentElement.classList.remove(className); } localStorage.setItem(localStorageKey, resolvedMode); @@ -188,9 +217,9 @@ export function initThemeMode( if (resolvedMode === "auto") { if (e.matches) { - document.documentElement.classList.add(`${prefix}dark`); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove(`${prefix}dark`); + document.documentElement.classList.remove(className); } } }); @@ -206,9 +235,9 @@ export function initThemeMode( resolvedMode === "dark" || (resolvedMode === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) ) { - document.documentElement.classList.add(`${prefix}dark`); + document.documentElement.classList.add(className); } else { - document.documentElement.classList.remove(`${prefix}dark`); + document.documentElement.classList.remove(className); } } });