From 86c795338eca09e964d2a44f3ff86f805f1a3809 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sun, 27 Jul 2025 21:26:12 +0300 Subject: [PATCH 1/4] Search for `` in the project and warn if it's not found --- .changeset/dull-ducks-give.md | 5 + packages/ui/package.json | 2 +- packages/ui/scripts/generate-metadata.test.ts | 2 +- packages/ui/src/cli/commands/build.ts | 17 ++ packages/ui/src/cli/commands/dev.ts | 26 ++- packages/ui/src/cli/commands/setup-config.ts | 37 +---- packages/ui/src/cli/commands/setup-init.ts | 2 +- packages/ui/src/cli/utils/add-import.test.ts | 2 +- packages/ui/src/cli/utils/add-plugin.test.ts | 2 +- .../ui/src/cli/utils/add-to-config.test.ts | 2 +- .../ui/src/cli/utils/compare-nodes.test.ts | 2 +- packages/ui/src/cli/utils/create-config.ts | 24 +++ .../src/cli/utils/create-init-logger.test.ts | 152 ++++++++++++++++++ .../ui/src/cli/utils/create-init-logger.ts | 74 +++++++++ .../utils/extract-component-imports.test.ts | 2 +- packages/ui/src/cli/utils/get-config.ts | 3 +- .../ui/src/cli/utils/normalize-path.test.ts | 2 +- .../src/cli/utils/update-build-config.test.ts | 2 +- .../src/cli/utils/wrap-default-export.test.ts | 2 +- .../ui/src/helpers/apply-prefix-v3.test.ts | 2 +- packages/ui/src/helpers/apply-prefix.test.ts | 2 +- .../helpers/convert-utilities-to-v4.test.ts | 2 +- packages/ui/src/helpers/get.test.ts | 2 +- packages/ui/src/helpers/is-equal.test.ts | 2 +- packages/ui/src/helpers/merge-refs.test.ts | 17 +- packages/ui/src/helpers/resolve-props.test.ts | 7 +- packages/ui/src/helpers/resolve-theme.test.ts | 3 +- packages/ui/src/helpers/strip-dark.test.ts | 2 +- .../src/helpers/without-theming-props.test.ts | 2 +- packages/ui/vitest.config.js | 2 +- 30 files changed, 328 insertions(+), 75 deletions(-) create mode 100644 .changeset/dull-ducks-give.md create mode 100644 packages/ui/src/cli/utils/create-config.ts create mode 100644 packages/ui/src/cli/utils/create-init-logger.test.ts create mode 100644 packages/ui/src/cli/utils/create-init-logger.ts diff --git a/.changeset/dull-ducks-give.md b/.changeset/dull-ducks-give.md new file mode 100644 index 0000000000..51453b4c02 --- /dev/null +++ b/.changeset/dull-ducks-give.md @@ -0,0 +1,5 @@ +--- +"flowbite-react": patch +--- + +Search for `` in the project and warn if it's not found instead of warning all the time diff --git a/packages/ui/package.json b/packages/ui/package.json index e6e6db286c..b44ba2830c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -257,7 +257,7 @@ "prepack": "clean-package", "prepare": "bun run generate-metadata", "prepublishOnly": "bun run build", - "test": "bun test scripts && vitest", + "test": "bun test scripts src/cli src/helpers && vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, diff --git a/packages/ui/scripts/generate-metadata.test.ts b/packages/ui/scripts/generate-metadata.test.ts index 0f46762dbb..3fe89ffc19 100644 --- a/packages/ui/scripts/generate-metadata.test.ts +++ b/packages/ui/scripts/generate-metadata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { extractClassList, extractDependencyList } from "./generate-metadata"; describe("extractClassList", () => { diff --git a/packages/ui/src/cli/commands/build.ts b/packages/ui/src/cli/commands/build.ts index e15a88be52..d29a8ceb21 100644 --- a/packages/ui/src/cli/commands/build.ts +++ b/packages/ui/src/cli/commands/build.ts @@ -1,6 +1,7 @@ import fs from "fs/promises"; import { allowedExtensions, automaticClassGenerationMessage, classListFilePath, excludeDirs } from "../consts"; import { buildClassList } from "../utils/build-class-list"; +import { createInitLogger } from "../utils/create-init-logger"; import { extractComponentImports } from "../utils/extract-component-imports"; import { findFiles } from "../utils/find-files"; import { getConfig } from "../utils/get-config"; @@ -13,11 +14,24 @@ export async function build() { try { const config = await getConfig(); await setupInit(config); + const initLogger = createInitLogger(config); const importedComponents: string[] = []; if (config.components.length) { console.warn(automaticClassGenerationMessage); + + if (initLogger.isCustomConfig) { + const files = await findFiles({ + patterns: allowedExtensions.map((ext) => `**/*${ext}`), + excludeDirs, + }); + + for (const file of files) { + const content = await fs.readFile(file, "utf-8"); + initLogger.check(file, content); + } + } } else { const files = await findFiles({ patterns: allowedExtensions.map((ext) => `**/*${ext}`), @@ -27,6 +41,7 @@ export async function build() { for (const file of files) { const content = await fs.readFile(file, "utf-8"); const components = extractComponentImports(content); + initLogger.check(file, content); if (components.length) { importedComponents.push(...components); @@ -34,6 +49,8 @@ export async function build() { } } + initLogger.log(); + const classList = buildClassList({ components: config.components.length ? config.components : [...new Set(importedComponents)], dark: config.dark, diff --git a/packages/ui/src/cli/commands/dev.ts b/packages/ui/src/cli/commands/dev.ts index d93763ad87..7a90874651 100644 --- a/packages/ui/src/cli/commands/dev.ts +++ b/packages/ui/src/cli/commands/dev.ts @@ -13,6 +13,7 @@ import { initJsxFilePath, } from "../consts"; import { buildClassList } from "../utils/build-class-list"; +import { createInitLogger } from "../utils/create-init-logger"; import { extractComponentImports } from "../utils/extract-component-imports"; import { findFiles } from "../utils/find-files"; import { getClassList } from "../utils/get-class-list"; @@ -25,6 +26,7 @@ export async function dev() { await setupOutputDirectory(); let config = await getConfig(); await setupInit(config); + const initLogger = createInitLogger(config); if (config.components.length) { console.warn(automaticClassGenerationMessage); @@ -42,12 +44,15 @@ export async function dev() { for (const file of files) { const content = await fs.readFile(file, "utf-8"); const componentImports = extractComponentImports(content); + initLogger.check(file, content); if (componentImports.length) { importedComponentsMap[file] = componentImports; } } + initLogger.log(); + const newImportedComponents = [...new Set(Object.values(importedComponentsMap).flat())]; const newClassList = buildClassList({ components: config.components.length ? config.components : newImportedComponents, @@ -63,9 +68,19 @@ export async function dev() { // watch for changes async function handleChange(path: string, eventName: "change" | "unlink") { + if ([configFilePath, initFilePath, initJsxFilePath].includes(path)) { + config = await getConfig(); + await setupInit(config); + initLogger.config = config; + } + if (path === gitIgnoreFilePath) { + await setupGitIgnore(); + } + if (eventName === "change") { const content = await fs.readFile(path, "utf-8"); const componentImports = extractComponentImports(content); + initLogger.check(path, content); if (componentImports.length) { importedComponentsMap[path] = componentImports; @@ -75,17 +90,12 @@ export async function dev() { } if (eventName === "unlink") { delete importedComponentsMap[path]; + initLogger.checkedMap.delete(path); } - const newImportedComponents = [...new Set(Object.values(importedComponentsMap).flat())]; + initLogger.log(); - if ([configFilePath, initFilePath, initJsxFilePath].includes(path)) { - config = await getConfig(); - await setupInit(config); - } - if (path === gitIgnoreFilePath) { - await setupGitIgnore(); - } + const newImportedComponents = [...new Set(Object.values(importedComponentsMap).flat())]; const newClassList = buildClassList({ components: config.components.length ? config.components : newImportedComponents, diff --git a/packages/ui/src/cli/commands/setup-config.ts b/packages/ui/src/cli/commands/setup-config.ts index b4572e89d3..08db406303 100644 --- a/packages/ui/src/cli/commands/setup-config.ts +++ b/packages/ui/src/cli/commands/setup-config.ts @@ -3,36 +3,18 @@ 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 { createConfig, type Config } from "../utils/create-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, + const defaultConfig = createConfig({ version: await getTailwindVersion(), - }; + }); const writeTimeout = 10; try { @@ -102,19 +84,6 @@ export async function setupConfig(): Promise { 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")) { diff --git a/packages/ui/src/cli/commands/setup-init.ts b/packages/ui/src/cli/commands/setup-init.ts index 50ce162d11..a4d6ac903f 100644 --- a/packages/ui/src/cli/commands/setup-init.ts +++ b/packages/ui/src/cli/commands/setup-init.ts @@ -3,7 +3,7 @@ import type { namedTypes } from "ast-types"; import { parse } from "recast"; import { initFilePath, initJsxFilePath } from "../consts"; import { compareNodes } from "../utils/compare-nodes"; -import type { Config } from "./setup-config"; +import type { Config } from "../utils/create-config"; /** * Sets up the `.flowbite-react/init.tsx` file in the project. diff --git a/packages/ui/src/cli/utils/add-import.test.ts b/packages/ui/src/cli/utils/add-import.test.ts index 8e3b12db58..abe2f29f2a 100644 --- a/packages/ui/src/cli/utils/add-import.test.ts +++ b/packages/ui/src/cli/utils/add-import.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { addImport } from "./add-import"; describe("addImport", () => { diff --git a/packages/ui/src/cli/utils/add-plugin.test.ts b/packages/ui/src/cli/utils/add-plugin.test.ts index 3d31885487..6a7cbc46f6 100644 --- a/packages/ui/src/cli/utils/add-plugin.test.ts +++ b/packages/ui/src/cli/utils/add-plugin.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { addPlugin } from "./add-plugin"; describe("addPlugin", () => { diff --git a/packages/ui/src/cli/utils/add-to-config.test.ts b/packages/ui/src/cli/utils/add-to-config.test.ts index 53df5f2f73..7772343593 100644 --- a/packages/ui/src/cli/utils/add-to-config.test.ts +++ b/packages/ui/src/cli/utils/add-to-config.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "bun:test"; import * as recast from "recast"; -import { describe, expect, it } from "vitest"; import { addToConfig } from "./add-to-config"; describe("addToConfig", () => { diff --git a/packages/ui/src/cli/utils/compare-nodes.test.ts b/packages/ui/src/cli/utils/compare-nodes.test.ts index da94535daf..4aec00a81a 100644 --- a/packages/ui/src/cli/utils/compare-nodes.test.ts +++ b/packages/ui/src/cli/utils/compare-nodes.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "bun:test"; import { parse } from "recast"; -import { describe, expect, it } from "vitest"; import { compareNodes } from "./compare-nodes"; describe("compareNodes", () => { diff --git a/packages/ui/src/cli/utils/create-config.ts b/packages/ui/src/cli/utils/create-config.ts new file mode 100644 index 0000000000..d8085841da --- /dev/null +++ b/packages/ui/src/cli/utils/create-config.ts @@ -0,0 +1,24 @@ +export interface Config { + $schema: string; + components: string[]; + dark: boolean; + path: string; + prefix: string; + rsc: boolean; + tsx: boolean; + version: 3 | 4; +} + +export function createConfig(input: Partial = {}): Config { + return { + $schema: input.$schema ?? "https://unpkg.com/flowbite-react/schema.json", + components: input.components ?? [], + dark: input.dark ?? true, + path: input.path ?? "src/components", + // TODO: infer from project + prefix: input.prefix ?? "", + rsc: input.rsc ?? true, + tsx: input.tsx ?? true, + version: input.version ?? 4, + }; +} diff --git a/packages/ui/src/cli/utils/create-init-logger.test.ts b/packages/ui/src/cli/utils/create-init-logger.test.ts new file mode 100644 index 0000000000..0486d70bca --- /dev/null +++ b/packages/ui/src/cli/utils/create-init-logger.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "bun:test"; +import { hasThemeInit } from "./create-init-logger"; + +describe("hasThemeInit", () => { + it("should detect self-closing ThemeInit with space", () => { + expect(hasThemeInit("")).toBe(true); + }); + + it("should detect self-closing ThemeInit without space", () => { + expect(hasThemeInit("")).toBe(true); + }); + + it("should detect empty ThemeInit tags", () => { + expect(hasThemeInit("")).toBe(true); + }); + + it("should not detect ThemeInit with content", () => { + expect(hasThemeInit("some content")).toBe(false); + }); + + it("should not detect partial matches", () => { + expect(hasThemeInit("ThemeInit")).toBe(false); + }); + + it("should handle whitespace variations", () => { + expect(hasThemeInit("")).toBe(true); + expect(hasThemeInit("")).toBe(true); + }); + + it("should return false for empty content", () => { + expect(hasThemeInit("")).toBe(false); + }); + + it("should handle commented out ThemeInit", () => { + expect(hasThemeInit("")).toBe(false); + expect(hasThemeInit("{/* */}")).toBe(false); + expect(hasThemeInit("// ")).toBe(false); + }); + + it("should detect ThemeInit in JSX with children", () => { + const content = ` + import type { PropsWithChildren } from "react"; + import { ThemeInit } from "../.flowbite-react/init"; + + export default function RootLayout({ children }: PropsWithChildren) { + return ( + + + + {children} + + + ); + } + `; + expect(hasThemeInit(content)).toBe(true); + }); + + it("should detect ThemeInit in JSX", () => { + const content = ` + import { ThemeInit } from "../.flowbite-react/init"; + import { App } from "./App"; + + export default function App() { + return ( + <> + + + + ); + } + `; + expect(hasThemeInit(content)).toBe(true); + }); + + it("should not detect ThemeInit in JSX", () => { + const content = ` + import { ThemeInit } from "../.flowbite-react/init"; + import { App } from "./App"; + + export default function App() { + return ( + <> + + + ); + } + `; + expect(hasThemeInit(content)).toBe(false); + }); + + it("should detect multiple ThemeInit components", () => { + const content = ` + +
Some content
+ + `; + expect(hasThemeInit(content)).toBe(true); + }); + + it("should detect ThemeInit with attributes/props", () => { + expect(hasThemeInit('')).toBe(true); + expect(hasThemeInit('')).toBe(true); + expect(hasThemeInit('')).toBe(true); + }); + + it("should detect ThemeInit with newlines between tags", () => { + expect( + hasThemeInit(``), + ).toBe(true); + expect( + hasThemeInit(``), + ).toBe(true); + }); + + it("should handle more comment variations", () => { + expect(hasThemeInit("/* */")).toBe(false); + expect( + hasThemeInit(`/** + * + */`), + ).toBe(false); + expect(hasThemeInit("# ")).toBe(false); + }); + + it("should not detect case variations of ThemeInit", () => { + expect(hasThemeInit("")).toBe(false); + expect(hasThemeInit("")).toBe(false); + expect(hasThemeInit("")).toBe(false); + }); + + it("should detect ThemeInit in template literals", () => { + const content = ` + const template = \` +
+ +
+ \`; + `; + expect(hasThemeInit(content)).toBe(true); + }); + + it("should not detect malformed ThemeInit tags", () => { + expect(hasThemeInit("< ThemeInit/>")).toBe(false); + expect(hasThemeInit("")).toBe(false); + expect(hasThemeInit("")).toBe(false); + expect(hasThemeInit("<")).toBe(false); + }); +}); diff --git a/packages/ui/src/cli/utils/create-init-logger.ts b/packages/ui/src/cli/utils/create-init-logger.ts new file mode 100644 index 0000000000..0b92fb51df --- /dev/null +++ b/packages/ui/src/cli/utils/create-init-logger.ts @@ -0,0 +1,74 @@ +import { configFilePath, initFilePath, initJsxFilePath } from "../consts"; +import { createConfig, type Config } from "./create-config"; + +/** + * Creates a logger to track and warn about `` component usage. + * + * @param {Config} config - The configuration object used to check + */ +export function createInitLogger(config: Config) { + const defaultConfig = createConfig(); + + return { + config, + checkedMap: new Map(), + get isCustomConfig() { + return ( + this.config.dark !== defaultConfig.dark || + this.config.prefix !== defaultConfig.prefix || + this.config.version !== defaultConfig.version + ); + }, + get showWarning() { + return this.checkedMap.values().find((value) => value) === undefined; + }, + /** + * Checks if `` component is used in the given file content + * + * @param path - The path to the file being checked + * @param content - The file content to search in + */ + check(path: string, content: string) { + if (this.isCustomConfig) { + this.checkedMap.set(path, hasThemeInit(content)); + } + }, + /** + * Logs a warning if `` component is not used in the project and the configuration `dark`, `prefix` or `version` differs from default values. + */ + log() { + if (this.isCustomConfig && this.showWarning) { + console.warn( + `\n[!] Custom values detected in ${configFilePath}, render '' from ${config.tsx ? initFilePath : initJsxFilePath} 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.`, + ); + } + }, + }; +} + +/** + * Checks if `` component is used in the given file content + * + * @param content - The file content to search in + * @returns boolean indicating if ThemeInit is used + */ +export function hasThemeInit(content: string): boolean { + // First check for commented out ThemeInit + if (/(\/\/|