diff --git a/.changeset/dull-ducks-give.md b/.changeset/dull-ducks-give.md new file mode 100644 index 000000000..59033c6d4 --- /dev/null +++ b/.changeset/dull-ducks-give.md @@ -0,0 +1,10 @@ +--- +"flowbite-react": patch +--- + +Search for `` in the project and warn if it's not found instead of warning all the time + +### Changes + +- during commands `build` and `dev` check files content for custom configuration and display a warning if `` is not found +- switch tests in `src/cli` and `src/helpers` from `vitest` -> `bun:test` diff --git a/packages/ui/package.json b/packages/ui/package.json index e6e6db286..b44ba2830 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 0f46762db..3fe89ffc1 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 e15a88be5..d29a8ceb2 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 d93763ad8..7a9087465 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 b4572e89d..08db40630 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 50ce162d1..a4d6ac903 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 8e3b12db5..abe2f29f2 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 3d3188548..6a7cbc46f 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 53df5f2f7..777234359 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 da94535da..4aec00a81 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 000000000..d8085841d --- /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 000000000..0486d70bc --- /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 000000000..0b92fb51d --- /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 (/(\/\/|