20 | ```
21 |
22 | This works with both Vue (`class`) and React (`className`) syntax.
23 |
24 | ## Key Components
25 |
26 | 1. **File Processing**
27 | - Processes `.vue`, `.ts`, `.tsx`, `.js`, `.jsx`, `.html`, and `.blade.php` files
28 | - Skips `node_modules`, virtual files, and runtime files
29 | - Respects `.gitignore` patterns
30 |
31 | 2. **Class Transformations**
32 | - Handles state modifiers (hover, focus, active, etc.)
33 | - Supports responsive modifiers (sm, md, lg, xl, 2xl)
34 | - Combines multiple class attributes
35 | - Works with nested modifiers (e.g., `sm:hover`, `lg:focus`)
36 |
37 | 3. **Framework Support**
38 | - Vue: Uses `class` attribute
39 | - React: Uses `className` attribute
40 | - Automatically detects framework based on file extension
41 |
42 | ## Usage Example
43 |
44 | ```html
45 |
46 |
52 |
53 |
54 |
55 | ```
56 |
57 | The plugin integrates with Tailwind's JIT compiler, allowing for dynamic class generation based on your modifiers.
58 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | import { HTMLAttributes, DetailedHTMLProps } from 'react'
2 | import type { ViteDevServer } from 'vite'
3 |
4 | type VariantClassNames = {
5 | [key: `class${string}`]: string
6 | [key: `className${string}`]: string
7 | }
8 |
9 | export type DivWithVariants = DetailedHTMLProps<
10 | HTMLAttributes
,
11 | HTMLDivElement
12 | > &
13 | VariantClassNames
14 |
15 | // Plugin-specific types
16 | export interface ClassyOptions {
17 | /**
18 | * Framework language to use for class attribute
19 | * @default "vue"
20 | */
21 | language?: 'vue' | 'react' | 'blade'
22 |
23 | /**
24 | * Directory to output the generated class file
25 | * @default ".classy"
26 | */
27 | outputDir?: string
28 |
29 | /**
30 | * Filename for the generated class file
31 | * @default "output.classy.jsx"
32 | */
33 | outputFileName?: string
34 |
35 | /**
36 | * Files to watch for changes
37 | * @default [".vue", ".tsx", ".jsx", ".html", ".blade.php"]
38 | */
39 | files?: string[]
40 |
41 | /**
42 | * Auto-inject imports for classy functions
43 | * @default false
44 | */
45 | autoImport?: boolean
46 |
47 | /**
48 | * Debug mode
49 | * @default false
50 | */
51 | debug?: boolean
52 | }
53 |
54 | // Helper interface to represent Vite's dev server with the properties we need
55 | export interface ViteServer extends ViteDevServer {
56 | watcher: {
57 | on: (event: string, callback: (filePath: string) => void) => void
58 | add: (file: string) => void
59 | }
60 | middlewares: {
61 | use: (path: string, handler: (req: import('http').IncomingMessage, res: import('http').ServerResponse) => void) => void
62 | }
63 | httpServer: {
64 | once: (event: string, callback: () => void) => void
65 | } | null
66 | }
67 |
68 | // Type for React component props that can use class variants
69 | export type ClassyProps = TProps & {
70 | [key: `class:${string}`]: string
71 | [key: `className:${string}`]: string
72 | className?: string
73 | }
74 |
--------------------------------------------------------------------------------
/demos/laravel/database/migrations/0001_01_01_000002_create_jobs_table.php:
--------------------------------------------------------------------------------
1 | id();
16 | $table->string('queue')->index();
17 | $table->longText('payload');
18 | $table->unsignedTinyInteger('attempts');
19 | $table->unsignedInteger('reserved_at')->nullable();
20 | $table->unsignedInteger('available_at');
21 | $table->unsignedInteger('created_at');
22 | });
23 |
24 | Schema::create('job_batches', function (Blueprint $table) {
25 | $table->string('id')->primary();
26 | $table->string('name');
27 | $table->integer('total_jobs');
28 | $table->integer('pending_jobs');
29 | $table->integer('failed_jobs');
30 | $table->longText('failed_job_ids');
31 | $table->mediumText('options')->nullable();
32 | $table->integer('cancelled_at')->nullable();
33 | $table->integer('created_at');
34 | $table->integer('finished_at')->nullable();
35 | });
36 |
37 | Schema::create('failed_jobs', function (Blueprint $table) {
38 | $table->id();
39 | $table->string('uuid')->unique();
40 | $table->text('connection');
41 | $table->text('queue');
42 | $table->longText('payload');
43 | $table->longText('exception');
44 | $table->timestamp('failed_at')->useCurrent();
45 | });
46 | }
47 |
48 | /**
49 | * Reverse the migrations.
50 | */
51 | public function down(): void
52 | {
53 | Schema::dropIfExists('jobs');
54 | Schema::dropIfExists('job_batches');
55 | Schema::dropIfExists('failed_jobs');
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/demos/react/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config({
16 | extends: [
17 | // Remove ...tseslint.configs.recommended and replace with this
18 | ...tseslint.configs.recommendedTypeChecked,
19 | // Alternatively, use this for stricter rules
20 | ...tseslint.configs.strictTypeChecked,
21 | // Optionally, add this for stylistic rules
22 | ...tseslint.configs.stylisticTypeChecked,
23 | ],
24 | languageOptions: {
25 | // other options...
26 | parserOptions: {
27 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | })
32 | ```
33 |
34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35 |
36 | ```js
37 | // eslint.config.js
38 | import reactX from 'eslint-plugin-react-x'
39 | import reactDom from 'eslint-plugin-react-dom'
40 |
41 | export default tseslint.config({
42 | plugins: {
43 | // Add the react-x and react-dom plugins
44 | 'react-x': reactX,
45 | 'react-dom': reactDom,
46 | },
47 | rules: {
48 | // other rules...
49 | // Enable its recommended typescript rules
50 | ...reactX.configs['recommended-typescript'].rules,
51 | ...reactDom.configs.recommended.rules,
52 | },
53 | })
54 | ```
55 |
--------------------------------------------------------------------------------
/.cursor/rules/development-principles.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: true
5 | ---
6 | # Development Principles
7 |
8 | The UseClassy plugin follows strict development principles to maintain high performance, type safety, and minimal complexity. The core implementation in [src/useClassy.ts](mdc:src/useClassy.ts) demonstrates these principles.
9 |
10 | ## Performance First
11 |
12 | 1. **Vite Integration**
13 | - Direct integration with Vite's plugin system
14 | - Uses Vite's built-in file watching and caching
15 | - Avoids redundant file processing
16 | - Maintains minimal memory footprint
17 |
18 | 2. **Efficient Processing**
19 | - Uses regex-based transformations for speed
20 | - Implements caching to avoid reprocessing unchanged files
21 | - Skips unnecessary file reads and transformations
22 | - Processes files only when needed during development
23 |
24 | ## TypeScript Best Practices
25 |
26 | 1. **Strong Typing**
27 | - All functions and interfaces are fully typed
28 | - No `any` types unless absolutely necessary
29 | - Extensive use of TypeScript interfaces for plugin options
30 | - Type guards for safe runtime checks
31 |
32 | 2. **Type Safety**
33 | - Strict null checks enabled
34 | - Explicit return types on public functions
35 | - Proper error handling with type checking
36 | - No type assertions unless unavoidable
37 |
38 | ## Minimal Surface Area
39 |
40 | 1. **Code Organization**
41 | - Single primary file for core logic
42 | - Functions are small and focused
43 | - Clear separation of concerns
44 | - Minimal internal state
45 |
46 | 2. **Zero Dependencies**
47 | - Only uses Node.js built-in modules (fs, path, crypto)
48 | - No external runtime dependencies
49 | - Vite as only peer dependency
50 | - Reduces security and maintenance burden
51 |
52 | ## Example of Principles
53 |
54 | ```typescript
55 | // Bad: Multiple dependencies, complex logic
56 | import _ from 'lodash';
57 | import globby from 'globby';
58 | function processFiles(pattern: any) { ... }
59 |
60 | // Good: Built-in modules, clear types, focused logic
61 | import { readFileSync } from 'fs';
62 | import { join } from 'path';
63 | function processFile(filePath: string): string { ... }
64 | ```
65 |
66 | These principles ensure the plugin remains fast, reliable, and maintainable while keeping the codebase small and focused.
67 |
--------------------------------------------------------------------------------
/src/react.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, DependencyList } from 'react'
2 |
3 | /**
4 | * A custom React hook for combining class names
5 | * This is similar to the classnames package but optimized for react and memoization
6 | */
7 | export function useClassy(...args: (string | Record | (string | Record)[])[]): string {
8 | const deps: DependencyList = useMemo(
9 | () =>
10 | args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)),
11 | [JSON.stringify(args)],
12 | )
13 |
14 | return useMemo(() => {
15 | // Process each argument
16 | return args
17 | .map((arg) => {
18 | // Handle strings directly
19 | if (typeof arg === 'string') return arg
20 |
21 | // Handle objects (conditionally apply classes)
22 | if (arg && typeof arg === 'object' && !Array.isArray(arg)) {
23 | return Object.entries(arg)
24 | .filter(([, value]) => Boolean(value))
25 | .map(([key]) => key)
26 | .join(' ')
27 | }
28 |
29 | // Handle arrays by recursively flattening
30 | if (Array.isArray(arg)) {
31 | return arg
32 | .map(item => (typeof item === 'string' ? item : ''))
33 | .filter(Boolean)
34 | .join(' ')
35 | }
36 |
37 | return ''
38 | })
39 | .filter(Boolean)
40 | .join(' ')
41 | }, deps)
42 | }
43 |
44 | /**
45 | * A simple function for combining class names without hooks
46 | * Can be used in cases where hooks aren't appropriate
47 | */
48 | export function classy(...args: (string | Record | (string | Record)[])[]): string {
49 | return args
50 | .map((arg) => {
51 | // Handle strings directly
52 | if (typeof arg === 'string') return arg
53 |
54 | // Handle objects (conditionally apply classes)
55 | if (arg && typeof arg === 'object' && !Array.isArray(arg)) {
56 | return Object.entries(arg)
57 | .filter(([, value]) => Boolean(value))
58 | .map(([key]) => key)
59 | .join(' ')
60 | }
61 |
62 | // Handle arrays by flattening
63 | if (Array.isArray(arg)) {
64 | return arg
65 | .map(item => (typeof item === 'string' ? item : ''))
66 | .filter(Boolean)
67 | .join(' ')
68 | }
69 |
70 | return ''
71 | })
72 | .filter(Boolean)
73 | .join(' ')
74 | }
75 |
--------------------------------------------------------------------------------
/demos/laravel/config/filesystems.php:
--------------------------------------------------------------------------------
1 | env('FILESYSTEM_DISK', 'local'),
17 |
18 | /*
19 | |--------------------------------------------------------------------------
20 | | Filesystem Disks
21 | |--------------------------------------------------------------------------
22 | |
23 | | Below you may configure as many filesystem disks as necessary, and you
24 | | may even configure multiple disks for the same driver. Examples for
25 | | most supported storage drivers are configured here for reference.
26 | |
27 | | Supported drivers: "local", "ftp", "sftp", "s3"
28 | |
29 | */
30 |
31 | 'disks' => [
32 |
33 | 'local' => [
34 | 'driver' => 'local',
35 | 'root' => storage_path('app/private'),
36 | 'serve' => true,
37 | 'throw' => false,
38 | 'report' => false,
39 | ],
40 |
41 | 'public' => [
42 | 'driver' => 'local',
43 | 'root' => storage_path('app/public'),
44 | 'url' => env('APP_URL').'/storage',
45 | 'visibility' => 'public',
46 | 'throw' => false,
47 | 'report' => false,
48 | ],
49 |
50 | 's3' => [
51 | 'driver' => 's3',
52 | 'key' => env('AWS_ACCESS_KEY_ID'),
53 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
54 | 'region' => env('AWS_DEFAULT_REGION'),
55 | 'bucket' => env('AWS_BUCKET'),
56 | 'url' => env('AWS_URL'),
57 | 'endpoint' => env('AWS_ENDPOINT'),
58 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
59 | 'throw' => false,
60 | 'report' => false,
61 | ],
62 |
63 | ],
64 |
65 | /*
66 | |--------------------------------------------------------------------------
67 | | Symbolic Links
68 | |--------------------------------------------------------------------------
69 | |
70 | | Here you may configure the symbolic links that will be created when the
71 | | `storage:link` Artisan command is executed. The array keys should be
72 | | the locations of the links and the values should be their targets.
73 | |
74 | */
75 |
76 | 'links' => [
77 | public_path('storage') => storage_path('app/public'),
78 | ],
79 |
80 | ];
81 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-plugin-useclassy",
3 | "version": "2.6.0",
4 | "description": "UseClassy automatically appends class attributes to your components and lets you separate media queries, hover states, and other styles.",
5 | "scripts": {
6 | "dev": "pnpm -r dev",
7 | "test": "vitest run src/*",
8 | "test:watch": "vitest src/*",
9 | "release:major": "changelogen --release --major && pnpm publish",
10 | "release:minor": "changelogen --release --minor && pnpm publish",
11 | "release:patch": "changelogen --release --patch && pnpm publish",
12 | "build": "vite build",
13 | "build:website": "pnpm i --no-frozen-lockfile && cd demos/vue && pnpm run build",
14 | "prepublishOnly": "pnpm run build"
15 | },
16 | "keywords": [
17 | "vite-plugin",
18 | "useclassy",
19 | "classy",
20 | "tailwind",
21 | "css",
22 | "vue",
23 | "nuxt",
24 | "react",
25 | "next"
26 | ],
27 | "license": "MIT",
28 | "homepage": "https://github.com/jrmybtlr/useclassy#readme",
29 | "bugs": "https://github.com/jrmybtlr/useclassy/issues",
30 | "author": "Jeremy Butler ",
31 | "contributors": [
32 | {
33 | "name": "Jeremy Butler",
34 | "email": "jeremy@jeremymbutler.com",
35 | "url": "https://github.com/jrmybtlr"
36 | }
37 | ],
38 | "files": [
39 | "dist"
40 | ],
41 | "main": "dist/index.js",
42 | "type": "module",
43 | "types": "dist/index.d.ts",
44 | "exports": {
45 | ".": {
46 | "types": "./dist/index.d.ts",
47 | "import": "./dist/index.js",
48 | "require": "./dist/index.cjs"
49 | },
50 | "./react": {
51 | "types": "./dist/react.d.ts",
52 | "import": "./dist/react.js",
53 | "require": "./dist/react.cjs"
54 | }
55 | },
56 | "repository": {
57 | "type": "git",
58 | "url": "git+https://github.com/jrmybtlr/useclassy.git"
59 | },
60 | "devDependencies": {
61 | "@eslint/js": "^9.30.0",
62 | "@stylistic/eslint-plugin": "^4.4.1",
63 | "@types/react": "^18.3.23",
64 | "@typescript-eslint/eslint-plugin": "^8.35.0",
65 | "@typescript-eslint/parser": "^8.35.0",
66 | "@vitest/coverage-v8": "^3.2.4",
67 | "@vitest/ui": "^3.2.4",
68 | "changelogen": "^0.5.7",
69 | "eslint": "^9.30.0",
70 | "globals": "^16.2.0",
71 | "jsdom": "^26.1.0",
72 | "typescript": "^5.8.3",
73 | "typescript-eslint": "^8.35.0",
74 | "vite": "^7.0.0",
75 | "vite-plugin-dts": "^3.9.1",
76 | "vite-plugin-inspect": "^11.3.0",
77 | "vitest": "^3.2.4"
78 | },
79 | "peerDependencies": {
80 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
81 | "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
82 | },
83 | "resolutions": {
84 | "debug": "4.3.4",
85 | "supports-color": "8.1.1"
86 | },
87 | "dependencies": {
88 | "arktype": "^2.1.20"
89 | }
90 | }
--------------------------------------------------------------------------------
/demos/laravel/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://getcomposer.org/schema.json",
3 | "name": "laravel/laravel",
4 | "type": "project",
5 | "description": "The skeleton application for the Laravel framework.",
6 | "keywords": [
7 | "laravel",
8 | "framework"
9 | ],
10 | "license": "MIT",
11 | "require": {
12 | "php": "^8.2",
13 | "laravel/framework": "^12.0",
14 | "laravel/tinker": "^2.10.1",
15 | "useclassy/laravel": "*"
16 | },
17 | "require-dev": {
18 | "fakerphp/faker": "^1.23",
19 | "laravel/pail": "^1.2.2",
20 | "laravel/pint": "^1.24",
21 | "laravel/sail": "^1.41",
22 | "mockery/mockery": "^1.6",
23 | "nunomaduro/collision": "^8.6",
24 | "phpunit/phpunit": "^11.5.3"
25 | },
26 | "autoload": {
27 | "psr-4": {
28 | "App\\": "app/",
29 | "Database\\Factories\\": "database/factories/",
30 | "Database\\Seeders\\": "database/seeders/"
31 | }
32 | },
33 | "autoload-dev": {
34 | "psr-4": {
35 | "Tests\\": "tests/"
36 | }
37 | },
38 | "scripts": {
39 | "post-autoload-dump": [
40 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
41 | "@php artisan package:discover --ansi"
42 | ],
43 | "post-update-cmd": [
44 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
45 | ],
46 | "post-root-package-install": [
47 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
48 | ],
49 | "post-create-project-cmd": [
50 | "@php artisan key:generate --ansi",
51 | "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
52 | "@php artisan migrate --graceful --ansi"
53 | ],
54 | "dev": [
55 | "Composer\\Config::disableProcessTimeout",
56 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
57 | ],
58 | "test": [
59 | "@php artisan config:clear --ansi",
60 | "@php artisan test"
61 | ]
62 | },
63 | "extra": {
64 | "laravel": {
65 | "dont-discover": []
66 | }
67 | },
68 | "config": {
69 | "optimize-autoloader": true,
70 | "preferred-install": "dist",
71 | "sort-packages": true,
72 | "allow-plugins": {
73 | "pestphp/pest-plugin": true,
74 | "php-http/discovery": true
75 | }
76 | },
77 | "repositories": [
78 | {
79 | "type": "path",
80 | "url": "../../../useclassy-laravel"
81 | }
82 | ],
83 | "minimum-stability": "dev",
84 | "prefer-stable": true
85 | }
86 |
--------------------------------------------------------------------------------
/demos/vue/app/components/ClassExample.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
20 |
21 |
22 |
30 |
31 | {{ format === "react" ? "className" : "class"
32 | }}{{ key === "base" ? "" : ":" + key }}
33 |
34 | ="{{ value }}"
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {{ format === "vue" ? "class" : "className" }}="
45 |
46 |
47 |
53 |
54 | {{ value }}
55 |
56 |
57 | {{ formatCombinedClasses(key, value) }}
58 |
59 |
60 |
61 |
62 | "
63 |
64 |
65 |
66 |
67 |
68 |
91 |
--------------------------------------------------------------------------------
/src/tests/malformed-js.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { mergeClassAttributes } from '../core'
3 |
4 | describe('Malformed JavaScript Generation', () => {
5 | it('should not generate malformed JavaScript when merging function calls with static classes', () => {
6 | // This is the problematic case that generates malformed JavaScript
7 | const input = 'Content
'
8 |
9 | const result = mergeClassAttributes(input, 'className')
10 |
11 | console.log('Input:', input)
12 | console.log('Output:', result)
13 |
14 | // The current implementation generates:
15 | // className={getClassNames(), `flex`)}
16 | // This is invalid JavaScript syntax!
17 |
18 | // It should generate valid JavaScript instead
19 | expect(result).not.toContain('getClassNames(), `flex`)')
20 | expect(result).toContain('className=')
21 | expect(result).toContain('flex')
22 | expect(result).toContain('getClassNames')
23 |
24 | // The result should be valid JavaScript syntax
25 | // Valid options would be:
26 | // 1. className={`flex ${getClassNames()}`}
27 | // 2. className={getClassNames('flex')} (if function supports parameters)
28 | // 3. className={getClassNames()} with flex handled separately
29 | })
30 |
31 | it('should handle function calls with parameters correctly', () => {
32 | const input = 'Content
'
33 |
34 | const result = mergeClassAttributes(input, 'className')
35 |
36 | console.log('Input:', input)
37 | console.log('Output:', result)
38 |
39 | // Should not generate malformed syntax like: getClassNames(theme), `flex`)
40 | expect(result).not.toContain('getClassNames(theme), `flex`)')
41 | expect(result).toContain('className=')
42 | expect(result).toContain('flex')
43 | expect(result).toContain('getClassNames(theme')
44 | })
45 |
46 | it('should handle complex function calls without breaking syntax', () => {
47 | const input = 'Content
'
48 |
49 | const result = mergeClassAttributes(input, 'className')
50 |
51 | console.log('Input:', input)
52 | console.log('Output:', result)
53 |
54 | // Should not generate malformed syntax
55 | expect(result).not.toMatch(/getClassNames\([^)]+\), `[^`]+`\)/)
56 | expect(result).toContain('className=')
57 | expect(result).toContain('flex')
58 | expect(result).toContain('getClassNames(theme, isActive ? "active" : "inactive"')
59 | })
60 |
61 | it('should handle template literals correctly', () => {
62 | const input = 'Content
'
63 |
64 | const result = mergeClassAttributes(input, 'className')
65 |
66 | console.log('Input:', input)
67 | console.log('Output:', result)
68 |
69 | // Should merge template literals properly
70 | expect(result).toContain('className=')
71 | expect(result).toContain('flex')
72 | expect(result).toContain('baseClass')
73 | expect(result).toContain('active ?')
74 | })
75 | })
76 |
--------------------------------------------------------------------------------
/demos/laravel/README.md:
--------------------------------------------------------------------------------
1 | # Laravel UseClassy Demo
2 |
3 | This demo showcases UseClassy integration with Laravel and Blade templates. UseClassy automatically transforms `class:modifier="value"` syntax into standard Tailwind classes.
4 |
5 | ## What UseClassy Does
6 |
7 | UseClassy transforms this Blade syntax:
8 | ```blade
9 | Hello World
13 | ```
14 |
15 | Into this rendered HTML:
16 | ```html
17 | Hello World
18 | ```
19 |
20 | ## Setup
21 |
22 | 1. **Install dependencies:**
23 | ```bash
24 | npm install
25 | composer install
26 | ```
27 |
28 | 2. **Install UseClassy Laravel package:**
29 | ```bash
30 | composer require useclassy/laravel
31 | ```
32 |
33 | 3. **Environment setup:**
34 | ```bash
35 | cp .env.example .env
36 | php artisan key:generate
37 | ```
38 |
39 | 4. **Run development servers:**
40 | ```bash
41 | # Terminal 1: Laravel server
42 | php artisan serve
43 |
44 | # Terminal 2: Vite dev server
45 | npm run dev
46 | ```
47 |
48 | 5. **Visit the demo:**
49 | - Laravel app: http://localhost:8000
50 | - Vite dev server: http://localhost:5173
51 |
52 | ## How It Works
53 |
54 | 1. **Laravel Integration**: The `useclassy/laravel` Composer package automatically:
55 | - Registers via Laravel's package auto-discovery
56 | - Hooks into Blade compiler to transform UseClassy syntax
57 | - No manual PHP setup required!
58 |
59 | 2. **Blade Transformation**: The service provider transforms `class:modifier="value"` syntax during template compilation.
60 |
61 | 3. **Vite Integration**: The Vite plugin:
62 | - Scans `.blade.php` files for UseClassy classes
63 | - Generates a `.classy/output.classy.html` file for Tailwind JIT
64 | - Watches for changes and triggers hot reloads
65 |
66 | ## Configuration
67 |
68 | The Vite configuration in `vite.config.js`:
69 |
70 | ```javascript
71 | import useClassy from 'vite-plugin-useclassy'
72 |
73 | export default defineConfig({
74 | plugins: [
75 | useClassy({
76 | language: 'blade', // Enables Laravel integration
77 | debug: true, // Shows setup logs
78 | }),
79 | // ... other plugins
80 | ],
81 | })
82 | ```
83 |
84 | ## Demo Features
85 |
86 | The demo showcases:
87 | - ✅ Responsive modifiers: `class:lg="text-2xl"`
88 | - ✅ Hover states: `class:hover="underline"`
89 | - ✅ Dark mode: `class:dark="bg-gray-800"`
90 | - ✅ Multiple modifiers on one element
91 | - ✅ Hot reloading when editing Blade files
92 | - ✅ Automatic Tailwind JIT compilation
93 |
94 | ## File Structure
95 |
96 | ```
97 | demos/laravel/
98 | ├── resources/views/
99 | │ └── welcome.blade.php # Demo template
100 | ├── vite.config.js # UseClassy config
101 | └── .classy/
102 | └── output.classy.html # Generated classes
103 | ```
104 |
105 | The Laravel service provider is provided by the `useclassy/laravel` Composer package.
106 |
107 | ## Laravel Documentation
108 |
109 | For more information about Laravel itself, see the [Laravel documentation](https://laravel.com/docs).
--------------------------------------------------------------------------------
/demos/react/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/demos/laravel/config/cache.php:
--------------------------------------------------------------------------------
1 | env('CACHE_STORE', 'database'),
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Cache Stores
23 | |--------------------------------------------------------------------------
24 | |
25 | | Here you may define all of the cache "stores" for your application as
26 | | well as their drivers. You may even define multiple stores for the
27 | | same cache driver to group types of items stored in your caches.
28 | |
29 | | Supported drivers: "array", "database", "file", "memcached",
30 | | "redis", "dynamodb", "octane", "null"
31 | |
32 | */
33 |
34 | 'stores' => [
35 |
36 | 'array' => [
37 | 'driver' => 'array',
38 | 'serialize' => false,
39 | ],
40 |
41 | 'database' => [
42 | 'driver' => 'database',
43 | 'connection' => env('DB_CACHE_CONNECTION'),
44 | 'table' => env('DB_CACHE_TABLE', 'cache'),
45 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
46 | 'lock_table' => env('DB_CACHE_LOCK_TABLE'),
47 | ],
48 |
49 | 'file' => [
50 | 'driver' => 'file',
51 | 'path' => storage_path('framework/cache/data'),
52 | 'lock_path' => storage_path('framework/cache/data'),
53 | ],
54 |
55 | 'memcached' => [
56 | 'driver' => 'memcached',
57 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
58 | 'sasl' => [
59 | env('MEMCACHED_USERNAME'),
60 | env('MEMCACHED_PASSWORD'),
61 | ],
62 | 'options' => [
63 | // Memcached::OPT_CONNECT_TIMEOUT => 2000,
64 | ],
65 | 'servers' => [
66 | [
67 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
68 | 'port' => env('MEMCACHED_PORT', 11211),
69 | 'weight' => 100,
70 | ],
71 | ],
72 | ],
73 |
74 | 'redis' => [
75 | 'driver' => 'redis',
76 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
77 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
78 | ],
79 |
80 | 'dynamodb' => [
81 | 'driver' => 'dynamodb',
82 | 'key' => env('AWS_ACCESS_KEY_ID'),
83 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
84 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
85 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
86 | 'endpoint' => env('DYNAMODB_ENDPOINT'),
87 | ],
88 |
89 | 'octane' => [
90 | 'driver' => 'octane',
91 | ],
92 |
93 | ],
94 |
95 | /*
96 | |--------------------------------------------------------------------------
97 | | Cache Key Prefix
98 | |--------------------------------------------------------------------------
99 | |
100 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache
101 | | stores, there might be other applications using the same cache. For
102 | | that reason, you may prefix every cache key to avoid collisions.
103 | |
104 | */
105 |
106 | 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
107 |
108 | ];
109 |
--------------------------------------------------------------------------------
/demos/laravel/config/mail.php:
--------------------------------------------------------------------------------
1 | env('MAIL_MAILER', 'log'),
18 |
19 | /*
20 | |--------------------------------------------------------------------------
21 | | Mailer Configurations
22 | |--------------------------------------------------------------------------
23 | |
24 | | Here you may configure all of the mailers used by your application plus
25 | | their respective settings. Several examples have been configured for
26 | | you and you are free to add your own as your application requires.
27 | |
28 | | Laravel supports a variety of mail "transport" drivers that can be used
29 | | when delivering an email. You may specify which one you're using for
30 | | your mailers below. You may also add additional mailers if needed.
31 | |
32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
33 | | "postmark", "resend", "log", "array",
34 | | "failover", "roundrobin"
35 | |
36 | */
37 |
38 | 'mailers' => [
39 |
40 | 'smtp' => [
41 | 'transport' => 'smtp',
42 | 'scheme' => env('MAIL_SCHEME'),
43 | 'url' => env('MAIL_URL'),
44 | 'host' => env('MAIL_HOST', '127.0.0.1'),
45 | 'port' => env('MAIL_PORT', 2525),
46 | 'username' => env('MAIL_USERNAME'),
47 | 'password' => env('MAIL_PASSWORD'),
48 | 'timeout' => null,
49 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
50 | ],
51 |
52 | 'ses' => [
53 | 'transport' => 'ses',
54 | ],
55 |
56 | 'postmark' => [
57 | 'transport' => 'postmark',
58 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
59 | // 'client' => [
60 | // 'timeout' => 5,
61 | // ],
62 | ],
63 |
64 | 'resend' => [
65 | 'transport' => 'resend',
66 | ],
67 |
68 | 'sendmail' => [
69 | 'transport' => 'sendmail',
70 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
71 | ],
72 |
73 | 'log' => [
74 | 'transport' => 'log',
75 | 'channel' => env('MAIL_LOG_CHANNEL'),
76 | ],
77 |
78 | 'array' => [
79 | 'transport' => 'array',
80 | ],
81 |
82 | 'failover' => [
83 | 'transport' => 'failover',
84 | 'mailers' => [
85 | 'smtp',
86 | 'log',
87 | ],
88 | 'retry_after' => 60,
89 | ],
90 |
91 | 'roundrobin' => [
92 | 'transport' => 'roundrobin',
93 | 'mailers' => [
94 | 'ses',
95 | 'postmark',
96 | ],
97 | 'retry_after' => 60,
98 | ],
99 |
100 | ],
101 |
102 | /*
103 | |--------------------------------------------------------------------------
104 | | Global "From" Address
105 | |--------------------------------------------------------------------------
106 | |
107 | | You may wish for all emails sent by your application to be sent from
108 | | the same address. Here you may specify a name and address that is
109 | | used globally for all emails that are sent by your application.
110 | |
111 | */
112 |
113 | 'from' => [
114 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
115 | 'name' => env('MAIL_FROM_NAME', 'Example'),
116 | ],
117 |
118 | ];
119 |
--------------------------------------------------------------------------------
/demos/laravel/config/queue.php:
--------------------------------------------------------------------------------
1 | env('QUEUE_CONNECTION', 'database'),
17 |
18 | /*
19 | |--------------------------------------------------------------------------
20 | | Queue Connections
21 | |--------------------------------------------------------------------------
22 | |
23 | | Here you may configure the connection options for every queue backend
24 | | used by your application. An example configuration is provided for
25 | | each backend supported by Laravel. You're also free to add more.
26 | |
27 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
28 | |
29 | */
30 |
31 | 'connections' => [
32 |
33 | 'sync' => [
34 | 'driver' => 'sync',
35 | ],
36 |
37 | 'database' => [
38 | 'driver' => 'database',
39 | 'connection' => env('DB_QUEUE_CONNECTION'),
40 | 'table' => env('DB_QUEUE_TABLE', 'jobs'),
41 | 'queue' => env('DB_QUEUE', 'default'),
42 | 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
43 | 'after_commit' => false,
44 | ],
45 |
46 | 'beanstalkd' => [
47 | 'driver' => 'beanstalkd',
48 | 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
49 | 'queue' => env('BEANSTALKD_QUEUE', 'default'),
50 | 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
51 | 'block_for' => 0,
52 | 'after_commit' => false,
53 | ],
54 |
55 | 'sqs' => [
56 | 'driver' => 'sqs',
57 | 'key' => env('AWS_ACCESS_KEY_ID'),
58 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
59 | 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
60 | 'queue' => env('SQS_QUEUE', 'default'),
61 | 'suffix' => env('SQS_SUFFIX'),
62 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
63 | 'after_commit' => false,
64 | ],
65 |
66 | 'redis' => [
67 | 'driver' => 'redis',
68 | 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
69 | 'queue' => env('REDIS_QUEUE', 'default'),
70 | 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
71 | 'block_for' => null,
72 | 'after_commit' => false,
73 | ],
74 |
75 | ],
76 |
77 | /*
78 | |--------------------------------------------------------------------------
79 | | Job Batching
80 | |--------------------------------------------------------------------------
81 | |
82 | | The following options configure the database and table that store job
83 | | batching information. These options can be updated to any database
84 | | connection and table which has been defined by your application.
85 | |
86 | */
87 |
88 | 'batching' => [
89 | 'database' => env('DB_CONNECTION', 'sqlite'),
90 | 'table' => 'job_batches',
91 | ],
92 |
93 | /*
94 | |--------------------------------------------------------------------------
95 | | Failed Queue Jobs
96 | |--------------------------------------------------------------------------
97 | |
98 | | These options configure the behavior of failed queue job logging so you
99 | | can control how and where failed jobs are stored. Laravel ships with
100 | | support for storing failed jobs in a simple file or in a database.
101 | |
102 | | Supported drivers: "database-uuids", "dynamodb", "file", "null"
103 | |
104 | */
105 |
106 | 'failed' => [
107 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
108 | 'database' => env('DB_CONNECTION', 'sqlite'),
109 | 'table' => 'failed_jobs',
110 | ],
111 |
112 | ];
113 |
--------------------------------------------------------------------------------
/demos/laravel/config/auth.php:
--------------------------------------------------------------------------------
1 | [
17 | 'guard' => env('AUTH_GUARD', 'web'),
18 | 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
19 | ],
20 |
21 | /*
22 | |--------------------------------------------------------------------------
23 | | Authentication Guards
24 | |--------------------------------------------------------------------------
25 | |
26 | | Next, you may define every authentication guard for your application.
27 | | Of course, a great default configuration has been defined for you
28 | | which utilizes session storage plus the Eloquent user provider.
29 | |
30 | | All authentication guards have a user provider, which defines how the
31 | | users are actually retrieved out of your database or other storage
32 | | system used by the application. Typically, Eloquent is utilized.
33 | |
34 | | Supported: "session"
35 | |
36 | */
37 |
38 | 'guards' => [
39 | 'web' => [
40 | 'driver' => 'session',
41 | 'provider' => 'users',
42 | ],
43 | ],
44 |
45 | /*
46 | |--------------------------------------------------------------------------
47 | | User Providers
48 | |--------------------------------------------------------------------------
49 | |
50 | | All authentication guards have a user provider, which defines how the
51 | | users are actually retrieved out of your database or other storage
52 | | system used by the application. Typically, Eloquent is utilized.
53 | |
54 | | If you have multiple user tables or models you may configure multiple
55 | | providers to represent the model / table. These providers may then
56 | | be assigned to any extra authentication guards you have defined.
57 | |
58 | | Supported: "database", "eloquent"
59 | |
60 | */
61 |
62 | 'providers' => [
63 | 'users' => [
64 | 'driver' => 'eloquent',
65 | 'model' => env('AUTH_MODEL', App\Models\User::class),
66 | ],
67 |
68 | // 'users' => [
69 | // 'driver' => 'database',
70 | // 'table' => 'users',
71 | // ],
72 | ],
73 |
74 | /*
75 | |--------------------------------------------------------------------------
76 | | Resetting Passwords
77 | |--------------------------------------------------------------------------
78 | |
79 | | These configuration options specify the behavior of Laravel's password
80 | | reset functionality, including the table utilized for token storage
81 | | and the user provider that is invoked to actually retrieve users.
82 | |
83 | | The expiry time is the number of minutes that each reset token will be
84 | | considered valid. This security feature keeps tokens short-lived so
85 | | they have less time to be guessed. You may change this as needed.
86 | |
87 | | The throttle setting is the number of seconds a user must wait before
88 | | generating more password reset tokens. This prevents the user from
89 | | quickly generating a very large amount of password reset tokens.
90 | |
91 | */
92 |
93 | 'passwords' => [
94 | 'users' => [
95 | 'provider' => 'users',
96 | 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
97 | 'expire' => 60,
98 | 'throttle' => 60,
99 | ],
100 | ],
101 |
102 | /*
103 | |--------------------------------------------------------------------------
104 | | Password Confirmation Timeout
105 | |--------------------------------------------------------------------------
106 | |
107 | | Here you may define the number of seconds before a password confirmation
108 | | window expires and users are asked to re-enter their password via the
109 | | confirmation screen. By default, the timeout lasts for three hours.
110 | |
111 | */
112 |
113 | 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
114 |
115 | ];
116 |
--------------------------------------------------------------------------------
/demos/laravel/config/app.php:
--------------------------------------------------------------------------------
1 | env('APP_NAME', 'Laravel'),
17 |
18 | /*
19 | |--------------------------------------------------------------------------
20 | | Application Environment
21 | |--------------------------------------------------------------------------
22 | |
23 | | This value determines the "environment" your application is currently
24 | | running in. This may determine how you prefer to configure various
25 | | services the application utilizes. Set this in your ".env" file.
26 | |
27 | */
28 |
29 | 'env' => env('APP_ENV', 'production'),
30 |
31 | /*
32 | |--------------------------------------------------------------------------
33 | | Application Debug Mode
34 | |--------------------------------------------------------------------------
35 | |
36 | | When your application is in debug mode, detailed error messages with
37 | | stack traces will be shown on every error that occurs within your
38 | | application. If disabled, a simple generic error page is shown.
39 | |
40 | */
41 |
42 | 'debug' => (bool) env('APP_DEBUG', false),
43 |
44 | /*
45 | |--------------------------------------------------------------------------
46 | | Application URL
47 | |--------------------------------------------------------------------------
48 | |
49 | | This URL is used by the console to properly generate URLs when using
50 | | the Artisan command line tool. You should set this to the root of
51 | | the application so that it's available within Artisan commands.
52 | |
53 | */
54 |
55 | 'url' => env('APP_URL', 'http://localhost'),
56 |
57 | /*
58 | |--------------------------------------------------------------------------
59 | | Application Timezone
60 | |--------------------------------------------------------------------------
61 | |
62 | | Here you may specify the default timezone for your application, which
63 | | will be used by the PHP date and date-time functions. The timezone
64 | | is set to "UTC" by default as it is suitable for most use cases.
65 | |
66 | */
67 |
68 | 'timezone' => 'UTC',
69 |
70 | /*
71 | |--------------------------------------------------------------------------
72 | | Application Locale Configuration
73 | |--------------------------------------------------------------------------
74 | |
75 | | The application locale determines the default locale that will be used
76 | | by Laravel's translation / localization methods. This option can be
77 | | set to any locale for which you plan to have translation strings.
78 | |
79 | */
80 |
81 | 'locale' => env('APP_LOCALE', 'en'),
82 |
83 | 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
84 |
85 | 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
86 |
87 | /*
88 | |--------------------------------------------------------------------------
89 | | Encryption Key
90 | |--------------------------------------------------------------------------
91 | |
92 | | This key is utilized by Laravel's encryption services and should be set
93 | | to a random, 32 character string to ensure that all encrypted values
94 | | are secure. You should do this prior to deploying the application.
95 | |
96 | */
97 |
98 | 'cipher' => 'AES-256-CBC',
99 |
100 | 'key' => env('APP_KEY'),
101 |
102 | 'previous_keys' => [
103 | ...array_filter(
104 | explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
105 | ),
106 | ],
107 |
108 | /*
109 | |--------------------------------------------------------------------------
110 | | Maintenance Mode Driver
111 | |--------------------------------------------------------------------------
112 | |
113 | | These configuration options determine the driver used to determine and
114 | | manage Laravel's "maintenance mode" status. The "cache" driver will
115 | | allow maintenance mode to be controlled across multiple machines.
116 | |
117 | | Supported drivers: "file", "cache"
118 | |
119 | */
120 |
121 | 'maintenance' => [
122 | 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
123 | 'store' => env('APP_MAINTENANCE_STORE', 'database'),
124 | ],
125 |
126 | ];
127 |
--------------------------------------------------------------------------------
/demos/laravel/config/logging.php:
--------------------------------------------------------------------------------
1 | env('LOG_CHANNEL', 'stack'),
22 |
23 | /*
24 | |--------------------------------------------------------------------------
25 | | Deprecations Log Channel
26 | |--------------------------------------------------------------------------
27 | |
28 | | This option controls the log channel that should be used to log warnings
29 | | regarding deprecated PHP and library features. This allows you to get
30 | | your application ready for upcoming major versions of dependencies.
31 | |
32 | */
33 |
34 | 'deprecations' => [
35 | 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
36 | 'trace' => env('LOG_DEPRECATIONS_TRACE', false),
37 | ],
38 |
39 | /*
40 | |--------------------------------------------------------------------------
41 | | Log Channels
42 | |--------------------------------------------------------------------------
43 | |
44 | | Here you may configure the log channels for your application. Laravel
45 | | utilizes the Monolog PHP logging library, which includes a variety
46 | | of powerful log handlers and formatters that you're free to use.
47 | |
48 | | Available drivers: "single", "daily", "slack", "syslog",
49 | | "errorlog", "monolog", "custom", "stack"
50 | |
51 | */
52 |
53 | 'channels' => [
54 |
55 | 'stack' => [
56 | 'driver' => 'stack',
57 | 'channels' => explode(',', (string) env('LOG_STACK', 'single')),
58 | 'ignore_exceptions' => false,
59 | ],
60 |
61 | 'single' => [
62 | 'driver' => 'single',
63 | 'path' => storage_path('logs/laravel.log'),
64 | 'level' => env('LOG_LEVEL', 'debug'),
65 | 'replace_placeholders' => true,
66 | ],
67 |
68 | 'daily' => [
69 | 'driver' => 'daily',
70 | 'path' => storage_path('logs/laravel.log'),
71 | 'level' => env('LOG_LEVEL', 'debug'),
72 | 'days' => env('LOG_DAILY_DAYS', 14),
73 | 'replace_placeholders' => true,
74 | ],
75 |
76 | 'slack' => [
77 | 'driver' => 'slack',
78 | 'url' => env('LOG_SLACK_WEBHOOK_URL'),
79 | 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
80 | 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
81 | 'level' => env('LOG_LEVEL', 'critical'),
82 | 'replace_placeholders' => true,
83 | ],
84 |
85 | 'papertrail' => [
86 | 'driver' => 'monolog',
87 | 'level' => env('LOG_LEVEL', 'debug'),
88 | 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
89 | 'handler_with' => [
90 | 'host' => env('PAPERTRAIL_URL'),
91 | 'port' => env('PAPERTRAIL_PORT'),
92 | 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
93 | ],
94 | 'processors' => [PsrLogMessageProcessor::class],
95 | ],
96 |
97 | 'stderr' => [
98 | 'driver' => 'monolog',
99 | 'level' => env('LOG_LEVEL', 'debug'),
100 | 'handler' => StreamHandler::class,
101 | 'handler_with' => [
102 | 'stream' => 'php://stderr',
103 | ],
104 | 'formatter' => env('LOG_STDERR_FORMATTER'),
105 | 'processors' => [PsrLogMessageProcessor::class],
106 | ],
107 |
108 | 'syslog' => [
109 | 'driver' => 'syslog',
110 | 'level' => env('LOG_LEVEL', 'debug'),
111 | 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
112 | 'replace_placeholders' => true,
113 | ],
114 |
115 | 'errorlog' => [
116 | 'driver' => 'errorlog',
117 | 'level' => env('LOG_LEVEL', 'debug'),
118 | 'replace_placeholders' => true,
119 | ],
120 |
121 | 'null' => [
122 | 'driver' => 'monolog',
123 | 'handler' => NullHandler::class,
124 | ],
125 |
126 | 'emergency' => [
127 | 'path' => storage_path('logs/laravel.log'),
128 | ],
129 |
130 | ],
131 |
132 | ];
133 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🎩 UseClassy
2 |
3 | UseClassy transforms Tailwind variant attributes (`class:hover="..."`) into standard Tailwind classes (`hover:...`). This allows for cleaner component markup by separating base classes from stateful or responsive variants.
4 |
5 | ## Features
6 |
7 | - Transforms attributes like `class:hover="text-blue-500"` to standard `class="hover:text-blue-500"`.
8 | - Supports chaining modifiers like `class:dark:hover="text-blue-500"`.
9 | - Works seamlessly with React (`className`) and Vue/HTML (`class`).
10 | - Integrates with Vite's build process and dev server. No runtime overhead.
11 | - Smart Caching: Avoids reprocessing unchanged files during development.
12 | - Runs before Tailwind JIT compiler with HMR and TailwindMerge support.
13 |
14 | ## Installation
15 |
16 | ```bash
17 | # npm
18 | npm install vite-plugin-useclassy --save-dev
19 |
20 | # yarn
21 | yarn add vite-plugin-useclassy -D
22 |
23 | # pnpm
24 | pnpm add vite-plugin-useclassy -D
25 | ```
26 |
27 | ## Vite Configuration
28 |
29 | Add `useClassy` to your Vite plugins. It's recommended that you place it before Tailwind or other CSS processing plugins.
30 |
31 | ```ts
32 | // vite.config.ts
33 | import useClassy from "vite-plugin-useclassy";
34 |
35 | export default {
36 | plugins: [
37 | useClassy({
38 | language: "react", // or 'vue' or 'blade'
39 |
40 | // Optional: Customize the output directory. Defaults to '.classy'.
41 | // outputDir: '.classy',
42 |
43 | // Optional: Customize output file name. Defaults to 'output.classy.html'.
44 | // outputFileName: 'generated-classes.html'
45 |
46 | // Optional: Enable debugging. Defaults to false.
47 | // debug: true,
48 | }),
49 | // ... other plugins
50 | ],
51 | };
52 | ```
53 |
54 | ## React Usage (`className`)
55 |
56 | ### Variant Attributes
57 |
58 | ```tsx
59 | // Input (using class:variant attributes)
60 |
67 |
68 | // Output (after transformation by the plugin)
69 |
72 | ```
73 |
74 | ## Vue / HTML Usage (`class`)
75 |
76 | ```vue
77 |
78 |
85 |
86 |
87 |
90 |
91 | ```
92 |
93 | ## Laravel Blade Usage
94 |
95 | For Laravel applications, install the dedicated Composer package. The service provider will be automatically registered via Laravel's package auto-discovery.
96 |
97 | ```bash
98 | composer require useclassy/laravel
99 | ```
100 |
101 | ### Add 'blade' to the language option in your Vite configuration
102 |
103 | ```ts
104 | useClassy({
105 | language: "blade",
106 | });
107 | ```
108 |
109 | ### Blade Template Usage
110 |
111 | ```blade
112 |
113 | Responsive heading that changes on large screens and hover
114 |
115 | ```
116 |
117 | The package transforms these during Blade compilation:
118 |
119 | - `class:lg="text-3xl"` becomes `lg:text-3xl`
120 | - `class:hover="text-blue-600"` becomes `hover:text-blue-600`
121 | - `class:dark="bg-gray-800 text-white"` becomes `dark:bg-gray-800 dark:text-white`
122 |
123 | These transformed classes are merged with any existing `class` attributes.
124 |
125 | ### Requirements
126 |
127 | - PHP ^8.1
128 | - Laravel ^10.0|^11.0|^12.0
129 |
130 | ## Tailwind JIT Integration
131 |
132 | Add the `output.classy.html` as a source file in your tailwind config.
133 |
134 | For Tailwind 4
135 |
136 | ```css
137 | /* your-main-css-file.css */
138 | @import "tailwindcss";
139 | @source "./.classy/output.classy.html";
140 | ```
141 |
142 | For Tailwind 3 you need to add the following to your Tailwind config.
143 |
144 | ```json
145 | "content": [
146 | // ... other content paths
147 | "./.classy/output.classy.html"
148 | ]
149 | ```
150 |
151 | ## Tailwind IntelliSense
152 |
153 | Add the following to your editor settings to enable IntelliSense for UseClassy.
154 |
155 | ```json
156 | {
157 | "tailwindCSS.classAttributes": [
158 | ...other settings,
159 | "class:[\\w:-]*"
160 | ]
161 | }
162 | ```
163 |
164 | ## Debugging
165 |
166 | Enable debugging by setting `debug: true` in the plugin options. This will log detailed information about the plugin's operation to the console.
167 |
168 | ```ts
169 | useClassy({
170 | debug: true,
171 | });
172 | ```
173 |
174 | ## Processing Rules
175 |
176 | - Only processes files with `.vue`, `.tsx`, `.jsx`, `.html`, `.blade.php` extensions.
177 | - Does not process files in the `node_modules` directory.
178 | - Does not process files in `.gitignore` directories.
179 | - Does not process virtual modules.
180 |
181 | ## Contributing
182 |
183 | Contributions are welcome! Please open an issue or submit a pull request.
184 |
185 | ## License
186 |
187 | MIT
188 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## v2.6.0
2 |
3 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.5.0...v2.6.0)
4 |
5 | ### 🏡 Chore
6 |
7 | - **release:** V2.5.0 ([eedc7af](https://github.com/jrmybtlr/useclassy/commit/eedc7af))
8 |
9 | ### ❤️ Contributors
10 |
11 | - Jeremy Butler
12 |
13 | ## v2.5.0
14 |
15 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.4.0...v2.5.0)
16 |
17 | ### 🏡 Chore
18 |
19 | - **release:** V2.4.0 ([01f92ee](https://github.com/jrmybtlr/useclassy/commit/01f92ee))
20 | - **release:** V2.5.0 ([dc0030d](https://github.com/jrmybtlr/useclassy/commit/dc0030d))
21 | - **release:** V2.6.0 ([56643d9](https://github.com/jrmybtlr/useclassy/commit/56643d9))
22 | - **release:** V2.7.0 ([af88b11](https://github.com/jrmybtlr/useclassy/commit/af88b11))
23 | - **release:** V2.8.0 ([f84056f](https://github.com/jrmybtlr/useclassy/commit/f84056f))
24 | - **release:** V2.4.0 ([53cc08b](https://github.com/jrmybtlr/useclassy/commit/53cc08b))
25 |
26 | ### ❤️ Contributors
27 |
28 | - Jeremy Butler
29 |
30 | ## v2.4.0
31 |
32 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.3.0...v2.4.0)
33 |
34 | ### 🏡 Chore
35 |
36 | - **release:** V2.4.0 ([01f92ee](https://github.com/jrmybtlr/useclassy/commit/01f92ee))
37 | - **release:** V2.5.0 ([dc0030d](https://github.com/jrmybtlr/useclassy/commit/dc0030d))
38 | - **release:** V2.6.0 ([56643d9](https://github.com/jrmybtlr/useclassy/commit/56643d9))
39 | - **release:** V2.7.0 ([af88b11](https://github.com/jrmybtlr/useclassy/commit/af88b11))
40 | - **release:** V2.8.0 ([f84056f](https://github.com/jrmybtlr/useclassy/commit/f84056f))
41 |
42 | ### ❤️ Contributors
43 |
44 | - Jeremy Butler
45 |
46 | ## v2.3.0
47 |
48 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.2.0...v2.3.0)
49 |
50 | ### 🤖 CI
51 |
52 | - Add nuxthub workflow ([f313491](https://github.com/jrmybtlr/useclassy/commit/f313491))
53 |
54 | ## v2.2.0
55 |
56 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.1.0...v2.2.0)
57 |
58 | ### 🚀 Enhancements
59 |
60 | - Add ESLint configuration and update dependencies for improved code quality ([7ae14c8](https://github.com/jrmybtlr/useclassy/commit/7ae14c8))
61 |
62 | ### 🩹 Fixes
63 |
64 | - Improve type definitions and enhance class name handling in useClassy and classy functions ([f6c2bf2](https://github.com/jrmybtlr/useclassy/commit/f6c2bf2))
65 |
66 | ### 💅 Refactors
67 |
68 | - Simplify class extraction logic in extractClasses function ([f2f8578](https://github.com/jrmybtlr/useclassy/commit/f2f8578))
69 |
70 | ### ❤️ Contributors
71 |
72 | - Jeremy Butler
73 |
74 | ## v2.1.0
75 |
76 | [compare changes](https://github.com/jrmybtlr/useclassy/compare/v2.0.0...v2.1.0)
77 |
78 | ### 🩹 Fixes
79 |
80 | - Update build command and change version to 0.1.0 ([32b92d7](https://github.com/jrmybtlr/useclassy/commit/32b92d7))
81 | - Change overflow style from scroll to hidden in Code.vue ([afb46e8](https://github.com/jrmybtlr/useclassy/commit/afb46e8))
82 | - Update useClassy function to use PluginOption type and enhance class extraction logic ([19207e2](https://github.com/jrmybtlr/useclassy/commit/19207e2))
83 | - Enhance class extraction logic in extractClasses function for better handling of static and modifier-derived classes ([14dde9d](https://github.com/jrmybtlr/useclassy/commit/14dde9d))
84 | - Update Tailwind integration instructions for clarity and accuracy ([bfdf762](https://github.com/jrmybtlr/useclassy/commit/bfdf762))
85 |
86 | ### 🏡 Chore
87 |
88 | - Remove npm-publish workflow and update tsconfig include paths ([0308828](https://github.com/jrmybtlr/useclassy/commit/0308828))
89 | - **release:** V2.1.0 ([7fd33b8](https://github.com/jrmybtlr/useclassy/commit/7fd33b8))
90 | - **release:** V2.1.0 ([df0c787](https://github.com/jrmybtlr/useclassy/commit/df0c787))
91 |
92 | ### ❤️ Contributors
93 |
94 | - Jeremy Butler
95 |
96 | ## v2.0.0
97 |
98 | ### 🚀 Enhancements
99 |
100 | - Enhance class handling in React components with support for className and modifiers ([6a0d19a](https://github.com/jrmybtlr/useclassy/commit/6a0d19a))
101 | - Update class handling and output file format in React and Vue demos ([81fc9ba](https://github.com/jrmybtlr/useclassy/commit/81fc9ba))
102 | - Update output file format to .html and enhance README documentation ([352e746](https://github.com/jrmybtlr/useclassy/commit/352e746))
103 |
104 | ### 🩹 Fixes
105 |
106 | - Update middleware function parameter from res to req ([f2033fa](https://github.com/jrmybtlr/useclassy/commit/f2033fa))
107 | - Update UseClassy plugin configuration to always apply and adjust text color in React demo ([faad96a](https://github.com/jrmybtlr/useclassy/commit/faad96a))
108 | - Rename package to reflect plugin usage in Vite ([e5cf232](https://github.com/jrmybtlr/useclassy/commit/e5cf232))
109 |
110 | ### 💅 Refactors
111 |
112 | - Update class handling and improve path aliases in configuration ([2e25172](https://github.com/jrmybtlr/useclassy/commit/2e25172))
113 | - Clean up class bindings in Vue and React components ([78dd8d8](https://github.com/jrmybtlr/useclassy/commit/78dd8d8))
114 |
115 | ### 📖 Documentation
116 |
117 | - Add development principles, file processing logic, and UseClassy plugin guide ([6ca272c](https://github.com/jrmybtlr/useclassy/commit/6ca272c))
118 | - Update README to clarify processing rules and contribution guidelines ([6221e70](https://github.com/jrmybtlr/useclassy/commit/6221e70))
119 |
120 | ### 🤖 CI
121 |
122 | - Add nuxthub workflow ([321aba7](https://github.com/jrmybtlr/useclassy/commit/321aba7))
123 | - Add nuxthub workflow ([599e65a](https://github.com/jrmybtlr/useclassy/commit/599e65a))
124 |
125 | ### ❤️ Contributors
126 |
127 | - Jeremy Butler
128 |
--------------------------------------------------------------------------------
/demos/laravel/config/database.php:
--------------------------------------------------------------------------------
1 | env('DB_CONNECTION', 'sqlite'),
20 |
21 | /*
22 | |--------------------------------------------------------------------------
23 | | Database Connections
24 | |--------------------------------------------------------------------------
25 | |
26 | | Below are all of the database connections defined for your application.
27 | | An example configuration is provided for each database system which
28 | | is supported by Laravel. You're free to add / remove connections.
29 | |
30 | */
31 |
32 | 'connections' => [
33 |
34 | 'sqlite' => [
35 | 'driver' => 'sqlite',
36 | 'url' => env('DB_URL'),
37 | 'database' => env('DB_DATABASE', database_path('database.sqlite')),
38 | 'prefix' => '',
39 | 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
40 | 'busy_timeout' => null,
41 | 'journal_mode' => null,
42 | 'synchronous' => null,
43 | ],
44 |
45 | 'mysql' => [
46 | 'driver' => 'mysql',
47 | 'url' => env('DB_URL'),
48 | 'host' => env('DB_HOST', '127.0.0.1'),
49 | 'port' => env('DB_PORT', '3306'),
50 | 'database' => env('DB_DATABASE', 'laravel'),
51 | 'username' => env('DB_USERNAME', 'root'),
52 | 'password' => env('DB_PASSWORD', ''),
53 | 'unix_socket' => env('DB_SOCKET', ''),
54 | 'charset' => env('DB_CHARSET', 'utf8mb4'),
55 | 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
56 | 'prefix' => '',
57 | 'prefix_indexes' => true,
58 | 'strict' => true,
59 | 'engine' => null,
60 | 'options' => extension_loaded('pdo_mysql') ? array_filter([
61 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
62 | ]) : [],
63 | ],
64 |
65 | 'mariadb' => [
66 | 'driver' => 'mariadb',
67 | 'url' => env('DB_URL'),
68 | 'host' => env('DB_HOST', '127.0.0.1'),
69 | 'port' => env('DB_PORT', '3306'),
70 | 'database' => env('DB_DATABASE', 'laravel'),
71 | 'username' => env('DB_USERNAME', 'root'),
72 | 'password' => env('DB_PASSWORD', ''),
73 | 'unix_socket' => env('DB_SOCKET', ''),
74 | 'charset' => env('DB_CHARSET', 'utf8mb4'),
75 | 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
76 | 'prefix' => '',
77 | 'prefix_indexes' => true,
78 | 'strict' => true,
79 | 'engine' => null,
80 | 'options' => extension_loaded('pdo_mysql') ? array_filter([
81 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
82 | ]) : [],
83 | ],
84 |
85 | 'pgsql' => [
86 | 'driver' => 'pgsql',
87 | 'url' => env('DB_URL'),
88 | 'host' => env('DB_HOST', '127.0.0.1'),
89 | 'port' => env('DB_PORT', '5432'),
90 | 'database' => env('DB_DATABASE', 'laravel'),
91 | 'username' => env('DB_USERNAME', 'root'),
92 | 'password' => env('DB_PASSWORD', ''),
93 | 'charset' => env('DB_CHARSET', 'utf8'),
94 | 'prefix' => '',
95 | 'prefix_indexes' => true,
96 | 'search_path' => 'public',
97 | 'sslmode' => 'prefer',
98 | ],
99 |
100 | 'sqlsrv' => [
101 | 'driver' => 'sqlsrv',
102 | 'url' => env('DB_URL'),
103 | 'host' => env('DB_HOST', 'localhost'),
104 | 'port' => env('DB_PORT', '1433'),
105 | 'database' => env('DB_DATABASE', 'laravel'),
106 | 'username' => env('DB_USERNAME', 'root'),
107 | 'password' => env('DB_PASSWORD', ''),
108 | 'charset' => env('DB_CHARSET', 'utf8'),
109 | 'prefix' => '',
110 | 'prefix_indexes' => true,
111 | // 'encrypt' => env('DB_ENCRYPT', 'yes'),
112 | // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
113 | ],
114 |
115 | ],
116 |
117 | /*
118 | |--------------------------------------------------------------------------
119 | | Migration Repository Table
120 | |--------------------------------------------------------------------------
121 | |
122 | | This table keeps track of all the migrations that have already run for
123 | | your application. Using this information, we can determine which of
124 | | the migrations on disk haven't actually been run on the database.
125 | |
126 | */
127 |
128 | 'migrations' => [
129 | 'table' => 'migrations',
130 | 'update_date_on_publish' => true,
131 | ],
132 |
133 | /*
134 | |--------------------------------------------------------------------------
135 | | Redis Databases
136 | |--------------------------------------------------------------------------
137 | |
138 | | Redis is an open source, fast, and advanced key-value store that also
139 | | provides a richer body of commands than a typical key-value system
140 | | such as Memcached. You may define your connection settings here.
141 | |
142 | */
143 |
144 | 'redis' => [
145 |
146 | 'client' => env('REDIS_CLIENT', 'phpredis'),
147 |
148 | 'options' => [
149 | 'cluster' => env('REDIS_CLUSTER', 'redis'),
150 | 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
151 | 'persistent' => env('REDIS_PERSISTENT', false),
152 | ],
153 |
154 | 'default' => [
155 | 'url' => env('REDIS_URL'),
156 | 'host' => env('REDIS_HOST', '127.0.0.1'),
157 | 'username' => env('REDIS_USERNAME'),
158 | 'password' => env('REDIS_PASSWORD'),
159 | 'port' => env('REDIS_PORT', '6379'),
160 | 'database' => env('REDIS_DB', '0'),
161 | ],
162 |
163 | 'cache' => [
164 | 'url' => env('REDIS_URL'),
165 | 'host' => env('REDIS_HOST', '127.0.0.1'),
166 | 'username' => env('REDIS_USERNAME'),
167 | 'password' => env('REDIS_PASSWORD'),
168 | 'port' => env('REDIS_PORT', '6379'),
169 | 'database' => env('REDIS_CACHE_DB', '1'),
170 | ],
171 |
172 | ],
173 |
174 | ];
175 |
--------------------------------------------------------------------------------
/src/blade.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Blade file processing for UseClassy plugin
3 | *
4 | * Blade files (.blade.php) require special handling because they are not part of Vite's
5 | * normal module graph. Unlike Vue/React files which Vite automatically discovers and
6 | * processes, Blade files are server-side templates that need manual discovery and watching.
7 | */
8 |
9 | import fs from 'fs'
10 | import path from 'path'
11 | import {
12 | extractClasses,
13 | CLASS_REGEX,
14 | CLASS_MODIFIER_REGEX,
15 | } from './core'
16 | import {
17 | shouldProcessFile,
18 | writeOutputFileDebounced,
19 | writeOutputFileDirect,
20 | } from './utils'
21 | import type { ViteServer } from './types'
22 |
23 | // Laravel setup functions
24 | export function isLaravelProject(): boolean {
25 | try {
26 | return fs.existsSync(path.join(process.cwd(), 'artisan'))
27 | && fs.existsSync(path.join(process.cwd(), 'app'))
28 | }
29 | catch {
30 | return false
31 | }
32 | }
33 |
34 | export function setupLaravelServiceProvider(debug = false): boolean {
35 | if (!isLaravelProject()) {
36 | if (debug) console.log('ℹ️ Not a Laravel project - skipping Laravel setup')
37 | return false
38 | }
39 |
40 | if (debug) {
41 | console.log('🎩 Laravel project detected!')
42 | console.log('📋 To enable UseClassy blade transformations:')
43 | console.log('')
44 | console.log(' composer require useclassy/laravel')
45 | console.log('')
46 | console.log('💡 The Vite plugin will handle class extraction for Tailwind JIT')
47 | console.log(' The Composer package will handle blade template transformations')
48 | }
49 |
50 | return true
51 | }
52 |
53 | export function findBladeFiles(dir: string, files: string[] = []): string[] {
54 | const items = fs.readdirSync(dir)
55 |
56 | for (const item of items) {
57 | const fullPath = path.join(dir, item)
58 | const stat = fs.statSync(fullPath)
59 |
60 | if (stat.isDirectory()) {
61 | // Skip ignored directories
62 | if (['node_modules', 'vendor', '.git', 'dist', 'build'].includes(item)) {
63 | continue
64 | }
65 | findBladeFiles(fullPath, files)
66 | }
67 | else if (item.endsWith('.blade.php')) {
68 | files.push(fullPath)
69 | }
70 | }
71 |
72 | return files
73 | }
74 |
75 | export function scanBladeFiles(
76 | ignoredDirectories: string[],
77 | allClassesSet: Set,
78 | fileClassMap: Map>,
79 | regenerateAllClasses: () => boolean,
80 | processCode: (code: string, currentGlobalClasses: Set) => {
81 | transformedCode: string
82 | classesChanged: boolean
83 | fileSpecificClasses: Set
84 | },
85 | outputDir: string,
86 | outputFileName: string,
87 | debug: boolean,
88 | ) {
89 | if (debug) console.log('🎩 Scanning Blade files...')
90 |
91 | try {
92 | const bladeFiles = findBladeFiles(process.cwd())
93 |
94 | if (debug) console.log(`🎩 Found ${bladeFiles.length} Blade files`)
95 |
96 | for (const file of bladeFiles) {
97 | if (!shouldProcessFile(file, ignoredDirectories)) {
98 | continue
99 | }
100 |
101 | try {
102 | const content = fs.readFileSync(file, 'utf-8')
103 | const result = processCode(content, allClassesSet)
104 |
105 | // Only store modifier-derived classes (from class:modifier attributes)
106 | if (result.classesChanged) {
107 | // Store only the modifier-derived classes for this file
108 | const modifierClasses = new Set()
109 |
110 | // Extract just the modifier classes from the processed result
111 | extractClasses(
112 | content,
113 | new Set(), // We don't need generatedClassesSet here
114 | modifierClasses, // This will contain only class:modifier derived classes
115 | CLASS_REGEX,
116 | CLASS_MODIFIER_REGEX,
117 | )
118 |
119 | fileClassMap.set(file, modifierClasses)
120 | regenerateAllClasses()
121 |
122 | // Don't write back to original files during scanning - only extract classes
123 | // This prevents interference with hot reloading and file watchers
124 |
125 | if (debug) {
126 | console.log(`🎩 Processed ${path.relative(process.cwd(), file)}: found ${modifierClasses.size} UseClassy modifier classes`)
127 | }
128 | }
129 | }
130 | catch (error) {
131 | if (debug) console.error(`🎩 Error reading ${file}:`, error)
132 | }
133 | }
134 |
135 | if (allClassesSet.size > 0) {
136 | if (debug) console.log(`🎩 Total classes found: ${allClassesSet.size}`)
137 | writeOutputFileDirect(allClassesSet, outputDir, outputFileName)
138 | }
139 | }
140 | catch (error) {
141 | if (debug) console.error('🎩 Error scanning Blade files:', error)
142 | }
143 | }
144 |
145 | export function setupBladeFileWatching(
146 | server: ViteServer,
147 | ignoredDirectories: string[],
148 | allClassesSet: Set,
149 | fileClassMap: Map>,
150 | regenerateAllClasses: () => boolean,
151 | processCode: (code: string, currentGlobalClasses: Set) => {
152 | transformedCode: string
153 | classesChanged: boolean
154 | fileSpecificClasses: Set
155 | },
156 | outputDir: string,
157 | outputFileName: string,
158 | isReact: boolean,
159 | debug: boolean,
160 | ) {
161 | if (debug) console.log('🎩 Setting up Blade file watching...')
162 |
163 | const bladeFiles = findBladeFiles(process.cwd())
164 |
165 | bladeFiles.forEach((file) => {
166 | if (shouldProcessFile(file, ignoredDirectories)) {
167 | server.watcher.add(file)
168 |
169 | if (debug) console.log(`🎩 Watching: ${path.relative(process.cwd(), file)}`)
170 | }
171 | })
172 |
173 | server.watcher.on('change', (filePath) => {
174 | if (filePath.endsWith('.blade.php')) {
175 | if (debug) console.log(`🎩 Blade file changed: ${path.relative(process.cwd(), filePath)}`)
176 |
177 | try {
178 | const content = fs.readFileSync(filePath, 'utf-8')
179 | const result = processCode(content, allClassesSet)
180 |
181 | if (result.classesChanged) {
182 | const modifierClasses = new Set()
183 |
184 | extractClasses(
185 | content,
186 | new Set(),
187 | modifierClasses,
188 | CLASS_REGEX,
189 | CLASS_MODIFIER_REGEX,
190 | )
191 |
192 | fileClassMap.set(filePath, modifierClasses)
193 | const globalChanged = regenerateAllClasses()
194 |
195 | // Update output file when blade classes change
196 | if (result.classesChanged || globalChanged) {
197 | if (debug) console.log('🎩 Blade file classes changed, updating output file.')
198 | writeOutputFileDebounced(allClassesSet, outputDir, outputFileName, isReact)
199 | }
200 | }
201 | }
202 | catch (error) {
203 | if (debug) console.error(`🎩 Error processing changed Blade file:`, error)
204 | }
205 | }
206 | })
207 | }
208 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { SUPPORTED_FILES } from './core'
4 |
5 | /**
6 | * Simple debounce function
7 | * @param func The function to debounce
8 | * @param wait The debounce delay in milliseconds
9 | */
10 | export function debounce unknown>(
12 | func: T,
13 | wait: number,
14 | ): (...args: Parameters) => void {
15 | let timeout: NodeJS.Timeout | null = null
16 |
17 | return function executedFunction(...args: Parameters) {
18 | const later = () => {
19 | timeout = null
20 | func(...args)
21 | }
22 |
23 | if (timeout) {
24 | clearTimeout(timeout)
25 | }
26 | timeout = setTimeout(later, wait)
27 | }
28 | }
29 |
30 | /**
31 | * Simple hash function for string values
32 | */
33 | export function hashFunction(str: string): number {
34 | let hash = 0
35 | for (let i = 0; i < str.length; i++) {
36 | hash = (hash << 5) - hash + str.charCodeAt(i)
37 | hash = hash & hash
38 | }
39 | return hash
40 | }
41 |
42 | /**
43 | * Load directories to ignore from .gitignore file
44 | */
45 | export function loadIgnoredDirectories(): string[] {
46 | try {
47 | const gitignorePath = path.join(process.cwd(), '.gitignore')
48 | if (!fs.existsSync(gitignorePath)) {
49 | return ['node_modules', 'dist']
50 | }
51 |
52 | const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8')
53 | return gitignoreContent
54 | .split('\n')
55 | .map(line => line.trim())
56 | .filter(
57 | line =>
58 | line
59 | && !line.startsWith('#')
60 | && !line.includes('*')
61 | && !line.startsWith('!'),
62 | )
63 | }
64 | catch (error) {
65 | console.warn(
66 | 'Could not load .gitignore file. Using default ignore patterns.',
67 | error,
68 | )
69 | return ['node_modules', 'dist']
70 | }
71 | }
72 |
73 | /**
74 | * Add output directory to .gitignore
75 | */
76 | export function writeGitignore(outputDir: string): void {
77 | const gitignorePath = path.join(process.cwd(), '.gitignore')
78 | const gitignoreEntry = `\n# Generated Classy files\n${outputDir}/\n`
79 |
80 | try {
81 | if (fs.existsSync(gitignorePath)) {
82 | const currentContent = fs.readFileSync(gitignorePath, 'utf-8')
83 | if (!currentContent.includes(`${outputDir}/`)) {
84 | fs.appendFileSync(gitignorePath, gitignoreEntry)
85 | }
86 | }
87 | else {
88 | fs.writeFileSync(gitignorePath, gitignoreEntry.trim())
89 | }
90 | }
91 | catch (error) {
92 | console.warn('Failed to update .gitignore file:', error)
93 | }
94 | }
95 |
96 | /**
97 | * Check if a file is in an ignored directory
98 | */
99 | export function isInIgnoredDirectory(
100 | filePath: string,
101 | ignoredDirectories: string[],
102 | ): boolean {
103 | if (!ignoredDirectories.length) return false
104 | const relativePath = path.relative(process.cwd(), filePath)
105 | return ignoredDirectories.some(
106 | dir => relativePath.startsWith(dir + '/') || relativePath === dir,
107 | )
108 | }
109 |
110 | const WRITE_DEBOUNCE_MS = 200
111 | let debouncedWrite: (() => void) | null = null
112 | let lastClassesSet: Set | null = null
113 | let lastOutputDir: string | null = null
114 | let lastOutputFileName: string | null = null
115 | let lastIsReact: boolean | null = null
116 |
117 | /**
118 | * Write the output file with all collected classes (Internal implementation)
119 | */
120 | function _writeOutputFile(
121 | allClassesSet: Set,
122 | outputDir: string,
123 | outputFileName: string,
124 | ): void {
125 | try {
126 | const allClasses = Array.from(allClassesSet).filter(
127 | cls => cls && cls.includes(':'),
128 | )
129 |
130 | // Check if file exists before deciding to skip write based on empty classes
131 | const filePath = path.join(process.cwd(), outputDir, outputFileName)
132 | if (allClasses.length === 0 && fs.existsSync(filePath)) {
133 | console.log('🎩 No modified classes detected, skipping write.')
134 | return
135 | }
136 |
137 | const dirPath = path.join(process.cwd(), outputDir)
138 | if (!fs.existsSync(dirPath)) {
139 | fs.mkdirSync(dirPath, { recursive: true })
140 | }
141 |
142 | const classyGitignorePath = path.join(dirPath, '.gitignore')
143 | if (!fs.existsSync(classyGitignorePath)) {
144 | fs.writeFileSync(
145 | classyGitignorePath,
146 | '# Ignore all files in this directory\n*',
147 | )
148 | }
149 |
150 | writeGitignore(outputDir)
151 | // Optimized string building without intermediate arrays
152 | let fileContent = '\n'
155 |
156 | for (const cls of allClasses) {
157 | fileContent += `\n`
158 | }
159 |
160 | if (allClasses.length > 0) {
161 | // Remove trailing newline if classes were added
162 | fileContent = fileContent.slice(0, -1) + '\n'
163 | }
164 |
165 | const tempFilePath = path.join(dirPath, `.${outputFileName}.tmp`)
166 | fs.writeFileSync(tempFilePath, fileContent, { encoding: 'utf-8' })
167 | fs.renameSync(tempFilePath, filePath)
168 | }
169 | catch (error) {
170 | console.error('🎩 Error writing output file:', error)
171 | }
172 | }
173 |
174 | /**
175 | * Schedules a debounced write operation.
176 | */
177 | function scheduleWriteOutputFile(
178 | allClassesSet: Set,
179 | outputDir: string,
180 | outputFileName: string,
181 | isReact: boolean,
182 | ): void {
183 | lastClassesSet = allClassesSet
184 | lastOutputDir = outputDir
185 | lastOutputFileName = outputFileName
186 | lastIsReact = isReact
187 |
188 | if (!debouncedWrite) {
189 | debouncedWrite = debounce(() => {
190 | if (
191 | lastClassesSet
192 | && lastOutputDir
193 | && lastOutputFileName !== null
194 | && lastIsReact !== null
195 | ) {
196 | _writeOutputFile(lastClassesSet, lastOutputDir, lastOutputFileName)
197 | }
198 | }, WRITE_DEBOUNCE_MS)
199 | }
200 |
201 | debouncedWrite()
202 | }
203 |
204 | // Export the debounced and direct functions
205 | export const writeOutputFileDebounced = scheduleWriteOutputFile
206 | export const writeOutputFileDirect = _writeOutputFile
207 |
208 | /**
209 | * Determine if a file should be processed
210 | */
211 | export function shouldProcessFile(
212 | filePath: string,
213 | ignoredDirectories: string[],
214 | ): boolean {
215 | if (isInIgnoredDirectory(filePath, ignoredDirectories)) {
216 | return false
217 | }
218 | if (!SUPPORTED_FILES.some(ext => filePath?.endsWith(ext))) {
219 | return false
220 | }
221 | const outputDirNormalized = lastOutputDir
222 | ? path.normalize(lastOutputDir)
223 | : null
224 | if (
225 | filePath.includes('node_modules')
226 | || filePath.includes('\0')
227 | || (outputDirNormalized && filePath.includes(outputDirNormalized))
228 | ) {
229 | return false
230 | }
231 | if (filePath.includes('virtual:') || filePath.includes('runtime')) {
232 | return false
233 | }
234 | return true
235 | }
236 |
--------------------------------------------------------------------------------
/src/tests/blade.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
2 | import fs from 'fs'
3 |
4 | // Mock fs module
5 | vi.mock('fs', () => ({
6 | default: {
7 | existsSync: vi.fn(),
8 | readFileSync: vi.fn(),
9 | writeFileSync: vi.fn(),
10 | mkdirSync: vi.fn(),
11 | readdirSync: vi.fn(),
12 | statSync: vi.fn(),
13 | },
14 | }))
15 |
16 | // Mock path module
17 | vi.mock('path', () => ({
18 | default: {
19 | join: vi.fn((...args) => args.join('/')),
20 | normalize: vi.fn(p => p),
21 | relative: vi.fn((base, filePath) => filePath.replace(base + '/', '')),
22 | },
23 | }))
24 |
25 | // Mock process.cwd()
26 | vi.stubGlobal('process', {
27 | ...process,
28 | cwd: vi.fn().mockReturnValue('/mock/cwd'),
29 | })
30 |
31 | // Mock console methods
32 | vi.stubGlobal('console', {
33 | log: vi.fn(),
34 | warn: vi.fn(),
35 | error: vi.fn(),
36 | })
37 |
38 | // Mock core functions
39 | vi.mock('../core', () => ({
40 | extractClasses: vi.fn(),
41 | CLASS_REGEX: /class\s*=\s*["']([^"']*)["']/g,
42 | CLASS_MODIFIER_REGEX: /class:(\w+)\s*=\s*["']([^"']*)["']/g,
43 | }))
44 |
45 | // Mock utils functions
46 | vi.mock('../utils', () => ({
47 | shouldProcessFile: vi.fn(),
48 | writeOutputFileDebounced: vi.fn(),
49 | writeOutputFileDirect: vi.fn(),
50 | }))
51 |
52 | // Import after mocking
53 | import {
54 | isLaravelProject,
55 | setupLaravelServiceProvider,
56 | findBladeFiles,
57 | scanBladeFiles,
58 | setupBladeFileWatching,
59 | } from '../blade'
60 |
61 | describe('blade module', () => {
62 | beforeEach(() => {
63 | vi.clearAllMocks()
64 | })
65 |
66 | afterEach(() => {
67 | vi.clearAllTimers()
68 | })
69 |
70 | describe('isLaravelProject', () => {
71 | it('should return true when artisan and app directory exist', () => {
72 | ;(fs.existsSync as Mock)
73 | .mockReturnValueOnce(true) // artisan
74 | .mockReturnValueOnce(true) // app
75 |
76 | const result = isLaravelProject()
77 |
78 | expect(result).toBe(true)
79 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/artisan')
80 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/app')
81 | })
82 |
83 | it('should return false when artisan does not exist', () => {
84 | ;(fs.existsSync as Mock)
85 | .mockReturnValueOnce(false) // artisan
86 |
87 | const result = isLaravelProject()
88 |
89 | expect(result).toBe(false)
90 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/artisan')
91 | expect(fs.existsSync).not.toHaveBeenCalledWith('/mock/cwd/app')
92 | })
93 |
94 | it('should return false when app directory does not exist', () => {
95 | ;(fs.existsSync as Mock)
96 | .mockReturnValueOnce(true) // artisan
97 | .mockReturnValueOnce(false) // app
98 |
99 | const result = isLaravelProject()
100 |
101 | expect(result).toBe(false)
102 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/artisan')
103 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/app')
104 | })
105 |
106 | it('should return false when fs.existsSync throws an error', () => {
107 | ;(fs.existsSync as Mock).mockImplementation(() => {
108 | throw new Error('File system error')
109 | })
110 |
111 | const result = isLaravelProject()
112 |
113 | expect(result).toBe(false)
114 | })
115 | })
116 |
117 | describe('setupLaravelServiceProvider', () => {
118 | it('should return false and log message when not a Laravel project', () => {
119 | ;(fs.existsSync as Mock)
120 | .mockReturnValueOnce(false) // artisan
121 |
122 | const result = setupLaravelServiceProvider(true)
123 |
124 | expect(result).toBe(false)
125 | expect(console.log).toHaveBeenCalledWith('ℹ️ Not a Laravel project - skipping Laravel setup')
126 | })
127 |
128 | it('should return true and log setup instructions when Laravel project detected', () => {
129 | ;(fs.existsSync as Mock)
130 | .mockReturnValueOnce(true) // artisan
131 | .mockReturnValueOnce(true) // app
132 |
133 | const result = setupLaravelServiceProvider(true)
134 |
135 | expect(result).toBe(true)
136 | expect(console.log).toHaveBeenCalledWith('🎩 Laravel project detected!')
137 | expect(console.log).toHaveBeenCalledWith('📋 To enable UseClassy blade transformations:')
138 | expect(console.log).toHaveBeenCalledWith('')
139 | expect(console.log).toHaveBeenCalledWith(' composer require useclassy/laravel')
140 | expect(console.log).toHaveBeenCalledWith('')
141 | expect(console.log).toHaveBeenCalledWith('💡 The Vite plugin will handle class extraction for Tailwind JIT')
142 | expect(console.log).toHaveBeenCalledWith(' The Composer package will handle blade template transformations')
143 | })
144 |
145 | it('should not log when debug is false', () => {
146 | ;(fs.existsSync as Mock)
147 | .mockReturnValueOnce(true) // artisan
148 | .mockReturnValueOnce(true) // app
149 |
150 | const result = setupLaravelServiceProvider(false)
151 |
152 | expect(result).toBe(true)
153 | expect(console.log).not.toHaveBeenCalled()
154 | })
155 | })
156 |
157 | describe('findBladeFiles', () => {
158 | it('should find blade files in directory', () => {
159 | const mockDirContents = ['file1.blade.php', 'file2.txt', 'subdir']
160 | const mockSubdirContents = ['file3.blade.php']
161 |
162 | ;(fs.readdirSync as Mock)
163 | .mockReturnValueOnce(mockDirContents)
164 | .mockReturnValueOnce(mockSubdirContents)
165 |
166 | ;(fs.statSync as Mock)
167 | .mockReturnValueOnce({ isDirectory: () => false }) // file1.blade.php
168 | .mockReturnValueOnce({ isDirectory: () => false }) // file2.txt
169 | .mockReturnValueOnce({ isDirectory: () => true }) // subdir
170 | .mockReturnValueOnce({ isDirectory: () => false }) // file3.blade.php
171 |
172 | const result = findBladeFiles('/test/dir')
173 |
174 | expect(result).toEqual([
175 | '/test/dir/file1.blade.php',
176 | '/test/dir/subdir/file3.blade.php',
177 | ])
178 | })
179 |
180 | it('should handle directories with no blade files', () => {
181 | const mockDirContents = ['file1.txt', 'file2.js']
182 |
183 | ;(fs.readdirSync as Mock).mockReturnValue(mockDirContents)
184 | ;(fs.statSync as Mock).mockReturnValue({ isDirectory: () => false })
185 |
186 | const result = findBladeFiles('/test/dir')
187 |
188 | expect(result).toEqual([])
189 | expect(fs.readdirSync).toHaveBeenCalledWith('/test/dir')
190 | })
191 |
192 | it('should handle empty directory', () => {
193 | ;(fs.readdirSync as Mock).mockReturnValue([])
194 |
195 | const result = findBladeFiles('/test/dir')
196 |
197 | expect(result).toEqual([])
198 | })
199 |
200 | it('should handle files array parameter', () => {
201 | const existingFiles = ['existing.blade.php']
202 | ;(fs.readdirSync as Mock).mockReturnValue(['new.blade.php'])
203 | ;(fs.statSync as Mock).mockReturnValue({ isDirectory: () => false })
204 |
205 | const result = findBladeFiles('/test/dir', existingFiles)
206 |
207 | expect(result).toEqual(['existing.blade.php', '/test/dir/new.blade.php'])
208 | })
209 | })
210 |
211 | describe('scanBladeFiles', () => {
212 | it('should handle basic scanning functionality', () => {
213 | // This test verifies that the function exists and can be called
214 | // The actual implementation testing would require more complex mocking
215 | expect(typeof scanBladeFiles).toBe('function')
216 | })
217 | })
218 |
219 | describe('setupBladeFileWatching', () => {
220 | it('should handle basic watching functionality', () => {
221 | // This test verifies that the function exists and can be called
222 | // The actual implementation testing would require more complex mocking
223 | expect(typeof setupBladeFileWatching).toBe('function')
224 | })
225 | })
226 | })
227 |
--------------------------------------------------------------------------------
/demos/vue/app/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
12 |
13 |
19 | 🎩
20 |
21 |
30 |
31 |
32 |
33 |
34 |
35 |
39 | Make your Tailwind variants fast, simple, and much more readable.
40 |
41 |
42 |
43 |
44 |
45 |
48 |
49 |
50 |
51 | npm install vite-plugin-useclassy --save-dev
52 |
53 |
54 |
55 |
56 |
61 |
62 |
63 |
64 | import useClassy from 'vite-plugin-useclassy';
65 |
66 | {
67 |
68 |
69 |
plugins: [
70 |
useClassy({
71 |
language: 'vue',
72 |
}),
73 |
// ... other plugins
74 |
],
75 |
76 |
77 | }
78 |
79 |
80 |
81 |
82 |
83 |
88 |
89 |
90 | {
91 |
92 |
93 |
@import "tailwindcss";
94 |
...update to your file location
95 |
96 | @source ".classy/output.classy.html";
97 |
98 |
...other config
99 |
100 |
101 | }
102 |
103 |
104 |
105 |
106 | For Tailwind 3, add the following to your Tailwind config.
107 |
108 |
109 | content: [
110 | // ... other content paths
111 | ".classy/output.classy.html"
112 | ]
113 |
114 |
115 |
117 |
118 |
119 |
124 |
125 |
126 | {
127 |
128 |
"tailwindCSS.classAttributes": [
129 |
...other settings,
130 |
"class:[\\w:-]*",
131 |
]
132 |
133 | }
134 |
135 |
136 |
137 |
138 |
139 |
158 |
159 |
160 |
161 |
162 |
219 |
--------------------------------------------------------------------------------
/demos/laravel/config/session.php:
--------------------------------------------------------------------------------
1 | env('SESSION_DRIVER', 'database'),
22 |
23 | /*
24 | |--------------------------------------------------------------------------
25 | | Session Lifetime
26 | |--------------------------------------------------------------------------
27 | |
28 | | Here you may specify the number of minutes that you wish the session
29 | | to be allowed to remain idle before it expires. If you want them
30 | | to expire immediately when the browser is closed then you may
31 | | indicate that via the expire_on_close configuration option.
32 | |
33 | */
34 |
35 | 'lifetime' => (int) env('SESSION_LIFETIME', 120),
36 |
37 | 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
38 |
39 | /*
40 | |--------------------------------------------------------------------------
41 | | Session Encryption
42 | |--------------------------------------------------------------------------
43 | |
44 | | This option allows you to easily specify that all of your session data
45 | | should be encrypted before it's stored. All encryption is performed
46 | | automatically by Laravel and you may use the session like normal.
47 | |
48 | */
49 |
50 | 'encrypt' => env('SESSION_ENCRYPT', false),
51 |
52 | /*
53 | |--------------------------------------------------------------------------
54 | | Session File Location
55 | |--------------------------------------------------------------------------
56 | |
57 | | When utilizing the "file" session driver, the session files are placed
58 | | on disk. The default storage location is defined here; however, you
59 | | are free to provide another location where they should be stored.
60 | |
61 | */
62 |
63 | 'files' => storage_path('framework/sessions'),
64 |
65 | /*
66 | |--------------------------------------------------------------------------
67 | | Session Database Connection
68 | |--------------------------------------------------------------------------
69 | |
70 | | When using the "database" or "redis" session drivers, you may specify a
71 | | connection that should be used to manage these sessions. This should
72 | | correspond to a connection in your database configuration options.
73 | |
74 | */
75 |
76 | 'connection' => env('SESSION_CONNECTION'),
77 |
78 | /*
79 | |--------------------------------------------------------------------------
80 | | Session Database Table
81 | |--------------------------------------------------------------------------
82 | |
83 | | When using the "database" session driver, you may specify the table to
84 | | be used to store sessions. Of course, a sensible default is defined
85 | | for you; however, you're welcome to change this to another table.
86 | |
87 | */
88 |
89 | 'table' => env('SESSION_TABLE', 'sessions'),
90 |
91 | /*
92 | |--------------------------------------------------------------------------
93 | | Session Cache Store
94 | |--------------------------------------------------------------------------
95 | |
96 | | When using one of the framework's cache driven session backends, you may
97 | | define the cache store which should be used to store the session data
98 | | between requests. This must match one of your defined cache stores.
99 | |
100 | | Affects: "dynamodb", "memcached", "redis"
101 | |
102 | */
103 |
104 | 'store' => env('SESSION_STORE'),
105 |
106 | /*
107 | |--------------------------------------------------------------------------
108 | | Session Sweeping Lottery
109 | |--------------------------------------------------------------------------
110 | |
111 | | Some session drivers must manually sweep their storage location to get
112 | | rid of old sessions from storage. Here are the chances that it will
113 | | happen on a given request. By default, the odds are 2 out of 100.
114 | |
115 | */
116 |
117 | 'lottery' => [2, 100],
118 |
119 | /*
120 | |--------------------------------------------------------------------------
121 | | Session Cookie Name
122 | |--------------------------------------------------------------------------
123 | |
124 | | Here you may change the name of the session cookie that is created by
125 | | the framework. Typically, you should not need to change this value
126 | | since doing so does not grant a meaningful security improvement.
127 | |
128 | */
129 |
130 | 'cookie' => env(
131 | 'SESSION_COOKIE',
132 | Str::snake((string) env('APP_NAME', 'laravel')).'_session'
133 | ),
134 |
135 | /*
136 | |--------------------------------------------------------------------------
137 | | Session Cookie Path
138 | |--------------------------------------------------------------------------
139 | |
140 | | The session cookie path determines the path for which the cookie will
141 | | be regarded as available. Typically, this will be the root path of
142 | | your application, but you're free to change this when necessary.
143 | |
144 | */
145 |
146 | 'path' => env('SESSION_PATH', '/'),
147 |
148 | /*
149 | |--------------------------------------------------------------------------
150 | | Session Cookie Domain
151 | |--------------------------------------------------------------------------
152 | |
153 | | This value determines the domain and subdomains the session cookie is
154 | | available to. By default, the cookie will be available to the root
155 | | domain and all subdomains. Typically, this shouldn't be changed.
156 | |
157 | */
158 |
159 | 'domain' => env('SESSION_DOMAIN'),
160 |
161 | /*
162 | |--------------------------------------------------------------------------
163 | | HTTPS Only Cookies
164 | |--------------------------------------------------------------------------
165 | |
166 | | By setting this option to true, session cookies will only be sent back
167 | | to the server if the browser has a HTTPS connection. This will keep
168 | | the cookie from being sent to you when it can't be done securely.
169 | |
170 | */
171 |
172 | 'secure' => env('SESSION_SECURE_COOKIE'),
173 |
174 | /*
175 | |--------------------------------------------------------------------------
176 | | HTTP Access Only
177 | |--------------------------------------------------------------------------
178 | |
179 | | Setting this value to true will prevent JavaScript from accessing the
180 | | value of the cookie and the cookie will only be accessible through
181 | | the HTTP protocol. It's unlikely you should disable this option.
182 | |
183 | */
184 |
185 | 'http_only' => env('SESSION_HTTP_ONLY', true),
186 |
187 | /*
188 | |--------------------------------------------------------------------------
189 | | Same-Site Cookies
190 | |--------------------------------------------------------------------------
191 | |
192 | | This option determines how your cookies behave when cross-site requests
193 | | take place, and can be used to mitigate CSRF attacks. By default, we
194 | | will set this value to "lax" to permit secure cross-site requests.
195 | |
196 | | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
197 | |
198 | | Supported: "lax", "strict", "none", null
199 | |
200 | */
201 |
202 | 'same_site' => env('SESSION_SAME_SITE', 'lax'),
203 |
204 | /*
205 | |--------------------------------------------------------------------------
206 | | Partitioned Cookies
207 | |--------------------------------------------------------------------------
208 | |
209 | | Setting this value to true will tie the cookie to the top-level site for
210 | | a cross-site context. Partitioned cookies are accepted by the browser
211 | | when flagged "secure" and the Same-Site attribute is set to "none".
212 | |
213 | */
214 |
215 | 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
216 |
217 | ];
218 |
--------------------------------------------------------------------------------
/src/core.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto'
2 | import { hashFunction } from './utils'
3 |
4 | // Supported file extensions
5 | export const SUPPORTED_FILES = ['.vue', '.ts', '.tsx', '.js', '.jsx', '.html', '.blade.php']
6 |
7 | // Performance constants
8 | const MAX_MODIFIER_DEPTH = 4
9 |
10 | // Base constants for class transformations
11 | export const CLASS_REGEX = /class="([^"]*)"(?![^>]*:class)/g
12 | export const CLASS_MODIFIER_REGEX = /class:([\w-:]+)="([^"]*)"/g
13 | export const MULTIPLE_CLASS_REGEX = /class="[^"]*"(\s*class="[^"]*")*/g
14 |
15 | // React-specific constants
16 | export const REACT_CLASS_REGEX = /className=(?:"([^"]*)"|{([^}]*)})(?![^>]*:)/g
17 | export const REACT_CLASS_MODIFIER_REGEX
18 | = /(?:className|class):([\w-:]+)="([^"]*)"/g
19 | export const REACT_MULTIPLE_CLASS_REGEX
20 | = /(?:className|class)=(?:"[^"]*"|{[^}]*})(?:\s*(?:className|class)=(?:"[^"]*"|{[^}]*}))*|(?:className|class)="[^"]*"(?:\s*(?:className|class)="[^"]*")*/g
21 |
22 | /**
23 | * Generates a hash string from the input string
24 | */
25 | export function hashString(str: string): string {
26 | return crypto.createHash('md5').update(str).digest('hex').slice(0, 8)
27 | }
28 |
29 | /**
30 | * Generates a cache key for a file
31 | */
32 | export function generateCacheKey(id: string, code: string): string {
33 | return hashFunction(id + code).toString()
34 | }
35 |
36 | /**
37 | * Extracts classes from the code, separating base classes and modifier-derived classes.
38 | */
39 | function processClassString(classStr: string, allFileClasses: Set): void {
40 | let start = 0
41 | const len = classStr.length
42 |
43 | for (let i = 0; i <= len; i++) {
44 | const char = classStr[i]
45 | if (char === ' ' || char === '\t' || char === '\n' || i === len) {
46 | if (i > start) {
47 | const cls = classStr.substring(start, i)
48 | if (cls) {
49 | allFileClasses.add(cls)
50 | }
51 | }
52 | while (i < len && /\s/.test(classStr[i + 1])) {
53 | i++
54 | }
55 | start = i + 1
56 | }
57 | }
58 | }
59 |
60 | /**
61 | * Extracts classes from the code
62 | */
63 | export function extractClasses(
64 | code: string,
65 | allFileClasses: Set,
66 | modifierDerivedClasses: Set,
67 | classRegex: RegExp,
68 | classModifierRegex: RegExp,
69 | ): void {
70 | allFileClasses.clear()
71 | modifierDerivedClasses.clear()
72 |
73 | // Extract base classes from class="..." or className="..."
74 | let classMatch
75 | while ((classMatch = classRegex.exec(code)) !== null) {
76 | // Handle quoted strings (group 1)
77 | const staticClasses = classMatch[1]
78 | if (staticClasses) {
79 | processClassString(staticClasses, allFileClasses)
80 | }
81 |
82 | // Handle JSX expressions (group 2)
83 | const jsxClasses = classMatch[2]
84 | if (jsxClasses) {
85 | const trimmedJsx = jsxClasses.trim()
86 | if (trimmedJsx.startsWith('`') && trimmedJsx.endsWith('`')) {
87 | const literalContent = trimmedJsx.slice(1, -1)
88 | const staticPart = literalContent.split('${')[0]
89 | if (staticPart) {
90 | processClassString(staticPart, allFileClasses)
91 | }
92 | }
93 | }
94 | }
95 |
96 | // Extract and process classes from class:modifier="..." or className:modifier="..."
97 | let modifierMatch
98 | while ((modifierMatch = classModifierRegex.exec(code)) !== null) {
99 | const modifiers = modifierMatch[1]
100 | const classes = modifierMatch[2]
101 |
102 | if (modifiers && classes) {
103 | // Process modifier classes with optimized string parsing
104 | let start = 0
105 | const len = classes.length
106 |
107 | for (let i = 0; i <= len; i++) {
108 | const char = classes[i]
109 | if (char === ' ' || char === '\t' || char === '\n' || i === len) {
110 | if (i > start) {
111 | const cls = classes.substring(start, i)
112 | if (cls) {
113 | const modifiedClass = `${modifiers}:${cls}`
114 | allFileClasses.add(modifiedClass)
115 | modifierDerivedClasses.add(modifiedClass)
116 |
117 | // Handle nested modifiers with depth limiting
118 | if (modifiers.includes(':')) {
119 | const modifierParts = modifiers.split(':')
120 | // Limit modifier depth to prevent exponential class generation
121 | const maxDepth = Math.min(modifierParts.length, MAX_MODIFIER_DEPTH)
122 | for (let j = 0; j < maxDepth; j++) {
123 | const part = modifierParts[j]
124 | if (part) {
125 | const partialModifiedClass = `${part}:${cls}`
126 | allFileClasses.add(partialModifiedClass)
127 | modifierDerivedClasses.add(partialModifiedClass)
128 | }
129 | }
130 | }
131 | }
132 | }
133 | // Skip to next non-whitespace
134 | while (i < len && /\s/.test(classes[i + 1])) {
135 | i++
136 | }
137 | start = i + 1
138 | }
139 | }
140 | }
141 | }
142 | }
143 |
144 | /**
145 | * Transforms class modifiers in the code
146 | */
147 | export function transformClassModifiers(
148 | code: string,
149 | generatedClassesSet: Set,
150 | classModifierRegex: RegExp,
151 | classAttrName: string,
152 | ): string {
153 | return code.replace(classModifierRegex, (match, modifiers, classes) => {
154 | if (!modifiers?.trim()) return match
155 |
156 | const modifierParts = modifiers.split(':')
157 |
158 | // Process each modifier part
159 | const modifiedClassesArr = classes
160 | .split(' ')
161 | .map((value: string) => value.trim())
162 | .filter(Boolean)
163 | .flatMap((value: string) => {
164 | const result = [`${modifiers}:${value}`]
165 |
166 | if (modifierParts.length > 1) {
167 | modifierParts.forEach((part: string) => {
168 | if (part) {
169 | result.push(`${part}:${value}`)
170 | }
171 | })
172 | }
173 |
174 | return result
175 | })
176 |
177 | // Add all modified classes to the set
178 | modifiedClassesArr.forEach((cls: string) => {
179 | if (
180 | cls
181 | && !cls.endsWith(':')
182 | && !cls.startsWith('\'')
183 | && !cls.endsWith('\'')
184 | ) {
185 | generatedClassesSet.add(cls)
186 | }
187 | })
188 |
189 | const finalAttrName = classAttrName === 'className' ? 'className' : classAttrName
190 | return `${finalAttrName}="${modifiedClassesArr.join(' ')}"`
191 | })
192 | }
193 |
194 | // Pre-compiled regex cache for performance
195 | const regexCache = new Map()
196 |
197 | function getCompiledRegexes(attrName: string) {
198 | let cached = regexCache.get(attrName)
199 | if (!cached) {
200 | cached = {
201 | multipleClassRegex: new RegExp(
202 | `((?:${attrName}|class)=(?:(?:"[^"]*")|(?:{[^}]*})))`
203 | + `(?:\\s+((?:${attrName}|class)=(?:(?:"[^"]*")|(?:{[^}]*}))))*`,
204 | 'g',
205 | ),
206 | attrFinderRegex: new RegExp(
207 | `(?:${attrName}|class)=(?:(?:"([^"]*)")|(?:{([^}]*)}))`,
208 | 'g',
209 | ),
210 | }
211 | regexCache.set(attrName, cached)
212 | }
213 | return cached
214 | }
215 |
216 | /**
217 | * Merges multiple class attributes into a single one
218 | */
219 | export function mergeClassAttributes(code: string, attrName: string): string {
220 | const { multipleClassRegex, attrFinderRegex } = getCompiledRegexes(attrName)
221 |
222 | return code.replace(multipleClassRegex, (match) => {
223 | const staticClasses: string[] = []
224 | let jsxExpr: string | null = null
225 | let isFunctionCall = false
226 |
227 | let singleAttrMatch
228 | while ((singleAttrMatch = attrFinderRegex.exec(match)) !== null) {
229 | const staticClassValue = singleAttrMatch[1] // Content of "..."
230 | const potentialJsx = singleAttrMatch[2] // Content of {...}
231 |
232 | if (staticClassValue?.trim()) {
233 | staticClasses.push(staticClassValue.trim())
234 | }
235 | else if (potentialJsx) {
236 | const currentJsx = potentialJsx.trim()
237 | if (currentJsx) {
238 | // Check if it's a template literal like {`...`}
239 | if (currentJsx.startsWith('`') && currentJsx.endsWith('`')) {
240 | const literalContent = currentJsx.slice(1, -1).trim()
241 | if (literalContent) {
242 | staticClasses.push(literalContent)
243 | }
244 | }
245 | else {
246 | const currentIsFunctionCall = /^[a-zA-Z_][\w.]*\(.*\)$/.test(currentJsx)
247 |
248 | if (!jsxExpr || (currentIsFunctionCall && !isFunctionCall)) {
249 | jsxExpr = currentJsx
250 | isFunctionCall = currentIsFunctionCall
251 | }
252 | else if (currentIsFunctionCall && isFunctionCall) {
253 | jsxExpr = currentJsx
254 | }
255 | else if (!jsxExpr) {
256 | jsxExpr = currentJsx
257 | isFunctionCall = false
258 | }
259 | }
260 | }
261 | }
262 | }
263 |
264 | const finalAttrName = attrName === 'className' ? 'className' : attrName
265 | const combinedStatic = staticClasses.join(' ').trim()
266 |
267 | if (jsxExpr) {
268 | if (!combinedStatic) {
269 | return `${finalAttrName}={${jsxExpr}}`
270 | }
271 |
272 | // Always use template literal approach for combining static classes with JSX expressions
273 | // This ensures valid JavaScript syntax regardless of the JSX expression type
274 | return `${finalAttrName}={\`${combinedStatic} \${${jsxExpr}}\`}`
275 | }
276 | else if (combinedStatic) {
277 | return `${finalAttrName}="${combinedStatic}"`
278 | }
279 | else {
280 | // Only warn in non-test environments to avoid noise during testing
281 | if (process.env.NODE_ENV !== 'test') {
282 | console.warn('No classes found in class attribute:', match)
283 | }
284 | return ''
285 | }
286 | })
287 | }
288 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { PluginOption } from 'vite'
2 |
3 | import {
4 | CLASS_REGEX,
5 | CLASS_MODIFIER_REGEX,
6 | REACT_CLASS_REGEX,
7 | REACT_CLASS_MODIFIER_REGEX,
8 | generateCacheKey,
9 | extractClasses,
10 | transformClassModifiers,
11 | mergeClassAttributes,
12 | } from './core'
13 |
14 | import {
15 | loadIgnoredDirectories,
16 | shouldProcessFile,
17 | writeOutputFileDebounced,
18 | writeOutputFileDirect,
19 | writeGitignore,
20 | } from './utils'
21 |
22 | import {
23 | scanBladeFiles,
24 | setupBladeFileWatching,
25 | setupLaravelServiceProvider,
26 | } from './blade'
27 |
28 | import type { ClassyOptions, ViteServer } from './types'
29 |
30 | /**
31 | * UseClassy Vite plugin
32 | * Transforms class:modifier attributes into Tailwind JIT-compatible class names.
33 | * @param options - Configuration options for the plugin
34 | * @param options.language - The framework language to use (e.g., "vue" or "react")
35 | * @param options.outputDir - The directory to output the generated class file
36 | * @param options.outputFileName - The filename for the generated class file
37 | * @param options.includePatterns - Array of glob patterns for files to include in processing
38 | * @param options.debug - Enable debug logging
39 | * @example
40 | * // vite.config.js
41 | * import useClassy from 'vite-plugin-useclassy';
42 | *
43 | * export default {
44 | * plugins: [
45 | * useClassy({
46 | * language: 'react',
47 | * outputDir: '.classy',
48 | * outputFileName: 'output.classy.html',
49 | * includePatterns: [],
50 | * debug: true
51 | * })
52 | * ]
53 | * }
54 | *
55 | */
56 |
57 | export default function useClassy(options: ClassyOptions = {}): PluginOption {
58 | let ignoredDirectories: string[] = []
59 | let allClassesSet: Set = new Set()
60 | let isBuild = false
61 | let initialScanComplete = false
62 | let lastWrittenClassCount = -1
63 |
64 | // Simple caching
65 | const transformCache: Map = new Map()
66 | const fileClassMap: Map> = new Map()
67 |
68 | function regenerateAllClasses(): boolean {
69 | const oldSize = allClassesSet.size
70 | allClassesSet.clear()
71 |
72 | // Regenerate from all tracked files
73 | for (const classes of fileClassMap.values()) {
74 | classes.forEach(className => allClassesSet.add(className))
75 | }
76 |
77 | return oldSize !== allClassesSet.size
78 | }
79 |
80 | // Options
81 | const outputDir = options.outputDir || '.classy'
82 | const outputFileName = options.outputFileName || 'output.classy.html'
83 | const isReact = options.language === 'react'
84 | const isBlade = options.language === 'blade'
85 | const debug = options.debug || false
86 |
87 | // Framework regex (Blade uses same syntax as Vue - both use 'class' attribute)
88 | const classRegex = isReact ? REACT_CLASS_REGEX : CLASS_REGEX
89 | const classModifierRegex = isReact
90 | ? REACT_CLASS_MODIFIER_REGEX
91 | : CLASS_MODIFIER_REGEX
92 | const classAttrName = isReact ? 'className' : 'class'
93 |
94 | // Class sets
95 | const generatedClassesSet: Set = new Set()
96 | const modifierDerivedClassesSet: Set = new Set()
97 |
98 | // Ensure .gitignore * is in .classy/ directory
99 | writeGitignore(outputDir)
100 |
101 | return {
102 | name: 'useClassy',
103 | enforce: 'pre',
104 |
105 | configResolved(config) {
106 | isBuild = config.command === 'build'
107 | ignoredDirectories = loadIgnoredDirectories()
108 |
109 | // Setup Laravel service provider if in blade mode
110 | if (isBlade && !isBuild) {
111 | setupLaravelServiceProvider(debug)
112 | }
113 |
114 | if (debug) {
115 | console.log(`🎩 Running in ${isBuild ? 'build' : 'dev'} mode.`)
116 | }
117 | },
118 |
119 | configureServer(server: ViteServer) {
120 | if (isBuild) return
121 |
122 | if (debug) console.log('🎩 Configuring dev server...')
123 |
124 | setupOutputEndpoint(server)
125 |
126 | // Only scan and watch Blade files if explicitly using blade language
127 | if (isBlade) {
128 | // Scan Blade files in dev mode too
129 | scanBladeFiles(
130 | ignoredDirectories,
131 | allClassesSet,
132 | fileClassMap,
133 | regenerateAllClasses,
134 | processCode,
135 | outputDir,
136 | outputFileName,
137 | debug,
138 | )
139 |
140 | // Watch Blade files for changes in dev mode
141 | setupBladeFileWatching(
142 | server,
143 | ignoredDirectories,
144 | allClassesSet,
145 | fileClassMap,
146 | regenerateAllClasses,
147 | processCode,
148 | outputDir,
149 | outputFileName,
150 | isReact,
151 | debug,
152 | )
153 | }
154 |
155 | server.httpServer?.once('listening', () => {
156 | if (
157 | initialScanComplete
158 | && allClassesSet.size > 0
159 | && lastWrittenClassCount !== allClassesSet.size
160 | ) {
161 | if (debug) console.log('🎩 Initial write on server ready.')
162 | writeOutputFileDirect(allClassesSet, outputDir, outputFileName)
163 | lastWrittenClassCount = allClassesSet.size
164 | }
165 | })
166 | },
167 |
168 | transform(code: string, id: string) {
169 | if (!shouldProcessFile(id, ignoredDirectories)) return null
170 |
171 | this.addWatchFile(id)
172 | const cacheKey = generateCacheKey(id, code)
173 |
174 | if (transformCache.has(cacheKey)) {
175 | if (debug)
176 | console.log('🎩 Cache key' + cacheKey + ': hit for:', id)
177 |
178 | return transformCache.get(cacheKey)
179 | }
180 |
181 | if (debug) console.log('🎩 Processing file:', id)
182 | if (debug) console.log('🎩 Cache key:', cacheKey)
183 |
184 | let transformedCode: string
185 | let directClassesChanged: boolean
186 | let fileSpecificClasses: Set
187 |
188 | try {
189 | const result = processCode(code, allClassesSet)
190 | transformedCode = result.transformedCode
191 | directClassesChanged = result.classesChanged
192 | fileSpecificClasses = result.fileSpecificClasses
193 | }
194 | catch (error) {
195 | console.error(`🎩 Error processing file ${id}:`, error)
196 | return null // Return original code without transformation
197 | }
198 |
199 | // Update file classes and regenerate global set
200 | fileClassMap.set(id, new Set(fileSpecificClasses))
201 | transformCache.set(cacheKey, transformedCode)
202 |
203 | const globalClassesChanged = regenerateAllClasses()
204 | const classesChanged = directClassesChanged || globalClassesChanged
205 |
206 | if (!isBuild && classesChanged) {
207 | if (debug)
208 | console.log('🎩 Classes changed, writing output file.')
209 | writeOutputFileDebounced(
210 | allClassesSet,
211 | outputDir,
212 | outputFileName,
213 | isReact,
214 | )
215 | }
216 |
217 | if (!initialScanComplete) {
218 | if (debug) console.log('🎩 Initial scan marked as complete.')
219 | initialScanComplete = true
220 | }
221 |
222 | return {
223 | code: transformedCode,
224 | map: null,
225 | }
226 | },
227 |
228 | buildStart() {
229 | if (debug) console.log('🎩 Build starting, resetting state.')
230 | allClassesSet = new Set()
231 | transformCache.clear()
232 | fileClassMap.clear()
233 | lastWrittenClassCount = -1
234 | initialScanComplete = false
235 |
236 | // Only scan Blade files during build if explicitly using blade language
237 | if (isBlade) {
238 | // Scan Blade files that aren't part of the module graph
239 | scanBladeFiles(
240 | ignoredDirectories,
241 | allClassesSet,
242 | fileClassMap,
243 | regenerateAllClasses,
244 | processCode,
245 | outputDir,
246 | outputFileName,
247 | debug,
248 | )
249 | }
250 | },
251 |
252 | buildEnd() {
253 | if (!isBuild) return
254 |
255 | if (allClassesSet.size > 0) {
256 | if (debug)
257 | console.log('🎩 Build ended, writing final output file.')
258 | writeOutputFileDirect(allClassesSet, outputDir, outputFileName)
259 | }
260 | else {
261 | if (debug)
262 | console.log('🎩 Build ended, no classes found to write.')
263 | }
264 | },
265 | }
266 |
267 | function setupOutputEndpoint(server: ViteServer) {
268 | server.middlewares.use(
269 | '/__useClassy__/generate-output',
270 | (_req: import('http').IncomingMessage, res: import('http').ServerResponse) => {
271 | if (debug)
272 | console.log(
273 | '🎩 Manual output generation requested via HTTP endpoint.',
274 | )
275 | writeOutputFileDirect(allClassesSet, outputDir, outputFileName)
276 | lastWrittenClassCount = allClassesSet.size
277 | res.statusCode = 200
278 | res.end(`Output file generated (${allClassesSet.size} classes)`)
279 | },
280 | )
281 | }
282 |
283 | function processCode(
284 | code: string,
285 | currentGlobalClasses: Set,
286 | ): {
287 | transformedCode: string
288 | classesChanged: boolean
289 | fileSpecificClasses: Set
290 | } {
291 | let classesChanged = false
292 | generatedClassesSet.clear()
293 | modifierDerivedClassesSet.clear()
294 |
295 | extractClasses(
296 | code,
297 | generatedClassesSet,
298 | modifierDerivedClassesSet,
299 | classRegex,
300 | classModifierRegex,
301 | )
302 |
303 | // Check which of the *modifier-derived* classes need to be added to the global set
304 | modifierDerivedClassesSet.forEach((className) => {
305 | if (!currentGlobalClasses.has(className)) {
306 | currentGlobalClasses.add(className)
307 | classesChanged = true
308 | }
309 | })
310 |
311 | // Transform the code (replace class:mod with actual classes)
312 | const transformedCodeWithModifiers = transformClassModifiers(
313 | code,
314 | generatedClassesSet,
315 | classModifierRegex,
316 | classAttrName,
317 | )
318 |
319 | // Merge multiple class attributes into one
320 | const finalTransformedCode = mergeClassAttributes(
321 | transformedCodeWithModifiers,
322 | classAttrName,
323 | )
324 |
325 | if (debug && classesChanged) {
326 | console.log(
327 | `🎩 Global class set size changed to ${currentGlobalClasses.size}`,
328 | )
329 | }
330 |
331 | // Return the final code, whether global classes changed,
332 | // and the set of ALL classes extracted specifically from this file
333 | return {
334 | transformedCode: finalTransformedCode,
335 | classesChanged,
336 | fileSpecificClasses: generatedClassesSet,
337 | }
338 | }
339 | }
340 |
341 | // Export React-specific utilities
342 | // React hooks are not fully tested yet
343 | export { classy, useClassy as useClassyHook } from './react'
344 | export { writeGitignore } from './utils'
345 | export type { ClassyOptions } from './types.d.ts'
346 |
--------------------------------------------------------------------------------
/src/tests/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
2 | import fs from 'fs'
3 | import path from 'path'
4 | import {
5 | debounce,
6 | hashFunction,
7 | loadIgnoredDirectories,
8 | writeGitignore,
9 | isInIgnoredDirectory,
10 | writeOutputFileDirect,
11 | shouldProcessFile,
12 | } from '../utils'
13 |
14 | // Mock fs and path modules
15 | vi.mock('fs', () => ({
16 | default: {
17 | existsSync: vi.fn(),
18 | readFileSync: vi.fn(),
19 | writeFileSync: vi.fn(),
20 | mkdirSync: vi.fn(),
21 | appendFileSync: vi.fn(),
22 | renameSync: vi.fn(),
23 | },
24 | }))
25 |
26 | vi.mock('path', () => ({
27 | default: {
28 | join: vi.fn((...args) => args.join('/')),
29 | normalize: vi.fn(p => p),
30 | relative: vi.fn((base, filePath) => filePath.replace(base + '/', '')),
31 | },
32 | }))
33 |
34 | // Mock process.cwd()
35 | vi.stubGlobal('process', {
36 | ...process,
37 | cwd: vi.fn().mockReturnValue('/mock/cwd'),
38 | })
39 |
40 | // Mock console methods
41 | vi.stubGlobal('console', {
42 | log: vi.fn(),
43 | warn: vi.fn(),
44 | error: vi.fn(),
45 | })
46 |
47 | describe('utils module', () => {
48 | beforeEach(() => {
49 | vi.clearAllMocks()
50 | })
51 |
52 | afterEach(() => {
53 | vi.clearAllTimers()
54 | })
55 |
56 | describe('debounce', () => {
57 | it('should debounce function calls', async () => {
58 | vi.useFakeTimers()
59 | const mockFn = vi.fn()
60 | const debouncedFn = debounce(mockFn, 100)
61 |
62 | // Call multiple times in quick succession
63 | debouncedFn()
64 | debouncedFn()
65 | debouncedFn()
66 |
67 | // Function should not have been called yet
68 | expect(mockFn).not.toHaveBeenCalled()
69 |
70 | // Advance timer
71 | vi.advanceTimersByTime(110)
72 |
73 | // Function should have been called once
74 | expect(mockFn).toHaveBeenCalledTimes(1)
75 | })
76 |
77 | it('should reset timer on subsequent calls', async () => {
78 | vi.useFakeTimers()
79 | const mockFn = vi.fn()
80 | const debouncedFn = debounce(mockFn, 100)
81 |
82 | // Call once
83 | debouncedFn()
84 |
85 | // Wait 50ms
86 | vi.advanceTimersByTime(50)
87 |
88 | // Call again, which should reset the timer
89 | debouncedFn()
90 |
91 | // Wait another 60ms (totaling 110ms from start)
92 | vi.advanceTimersByTime(60)
93 |
94 | // Function should not have been called yet (because of reset)
95 | expect(mockFn).not.toHaveBeenCalled()
96 |
97 | // Advance to reach delay after second call
98 | vi.advanceTimersByTime(50)
99 |
100 | // Now it should have been called
101 | expect(mockFn).toHaveBeenCalledTimes(1)
102 | })
103 | })
104 |
105 | describe('hashFunction', () => {
106 | it('should generate consistent hashes for same input', () => {
107 | const input = 'test string'
108 | const hash1 = hashFunction(input)
109 | const hash2 = hashFunction(input)
110 |
111 | expect(hash1).toBe(hash2)
112 | })
113 |
114 | it('should generate different hashes for different inputs', () => {
115 | const hash1 = hashFunction('test string 1')
116 | const hash2 = hashFunction('test string 2')
117 |
118 | expect(hash1).not.toBe(hash2)
119 | })
120 |
121 | it('should handle empty string', () => {
122 | const hash = hashFunction('')
123 | expect(typeof hash).toBe('number')
124 | })
125 |
126 | it('should handle special characters', () => {
127 | const hash1 = hashFunction('test')
128 | const hash2 = hashFunction('test!@#$%')
129 | expect(hash1).not.toBe(hash2)
130 | })
131 | })
132 |
133 | describe('loadIgnoredDirectories', () => {
134 | it('should read from .gitignore file when it exists', () => {
135 | (fs.existsSync as Mock).mockReturnValue(true);
136 | (fs.readFileSync as Mock).mockReturnValue(
137 | 'node_modules\ndist\n.cache\n# comment\n*.log\n!important',
138 | )
139 |
140 | const result = loadIgnoredDirectories()
141 |
142 | expect(fs.existsSync).toHaveBeenCalledWith('/mock/cwd/.gitignore')
143 | expect(fs.readFileSync).toHaveBeenCalledWith(
144 | '/mock/cwd/.gitignore',
145 | 'utf-8',
146 | )
147 | expect(result).toEqual(['node_modules', 'dist', '.cache'])
148 | })
149 |
150 | it('should return default directories when .gitignore doesn\'t exist', () => {
151 | (fs.existsSync as Mock).mockReturnValue(false)
152 |
153 | const result = loadIgnoredDirectories()
154 |
155 | expect(result).toEqual(['node_modules', 'dist'])
156 | })
157 |
158 | it('should handle fs errors gracefully', () => {
159 | (fs.existsSync as Mock).mockReturnValue(true);
160 | (fs.readFileSync as Mock).mockImplementation(() => {
161 | throw new Error('Test error')
162 | })
163 |
164 | const result = loadIgnoredDirectories()
165 |
166 | expect(console.warn).toHaveBeenCalled()
167 | expect(result).toEqual(['node_modules', 'dist'])
168 | })
169 |
170 | it('should filter out comments and patterns', () => {
171 | (fs.existsSync as Mock).mockReturnValue(true);
172 | (fs.readFileSync as Mock).mockReturnValue(
173 | '# This is a comment\nnode_modules\n# Another comment\ndist\n*.log\n!important\n.cache',
174 | )
175 |
176 | const result = loadIgnoredDirectories()
177 |
178 | expect(result).toEqual(['node_modules', 'dist', '.cache'])
179 | })
180 |
181 | it('should handle empty .gitignore file', () => {
182 | (fs.existsSync as Mock).mockReturnValue(true);
183 | (fs.readFileSync as Mock).mockReturnValue('')
184 |
185 | const result = loadIgnoredDirectories()
186 |
187 | expect(result).toEqual([])
188 | })
189 |
190 | it('should handle .gitignore with only comments', () => {
191 | (fs.existsSync as Mock).mockReturnValue(true);
192 | (fs.readFileSync as Mock).mockReturnValue('# Only comments\n# No actual entries')
193 |
194 | const result = loadIgnoredDirectories()
195 |
196 | expect(result).toEqual([])
197 | })
198 | })
199 |
200 | describe('writeGitignore', () => {
201 | it('should append to existing .gitignore', () => {
202 | (fs.existsSync as Mock).mockReturnValue(true);
203 | (fs.readFileSync as Mock).mockReturnValue('node_modules\ndist\n')
204 |
205 | writeGitignore('.classy')
206 |
207 | expect(fs.appendFileSync).toHaveBeenCalledWith(
208 | '/mock/cwd/.gitignore',
209 | expect.stringContaining('.classy/'),
210 | )
211 | })
212 |
213 | it('should create .gitignore if it doesn\'t exist', () => {
214 | (fs.existsSync as Mock).mockReturnValue(false)
215 |
216 | writeGitignore('.classy')
217 |
218 | expect(fs.writeFileSync).toHaveBeenCalledWith(
219 | '/mock/cwd/.gitignore',
220 | expect.stringContaining('.classy/'),
221 | )
222 | })
223 |
224 | it('should not append if entry already exists', () => {
225 | (fs.existsSync as Mock).mockReturnValue(true);
226 | (fs.readFileSync as Mock).mockReturnValue(
227 | 'node_modules\ndist\n.classy/\n',
228 | )
229 |
230 | writeGitignore('.classy')
231 |
232 | expect(fs.appendFileSync).not.toHaveBeenCalled()
233 | })
234 |
235 | it('should handle fs errors gracefully', () => {
236 | (fs.existsSync as Mock).mockReturnValue(true);
237 | (fs.readFileSync as Mock).mockImplementation(() => {
238 | throw new Error('Test error')
239 | })
240 |
241 | writeGitignore('.classy')
242 |
243 | expect(console.warn).toHaveBeenCalled()
244 | })
245 |
246 | it('should handle writeFileSync errors', () => {
247 | (fs.existsSync as Mock).mockReturnValue(false);
248 | (fs.writeFileSync as Mock).mockImplementation(() => {
249 | throw new Error('Write error')
250 | })
251 |
252 | writeGitignore('.classy')
253 |
254 | expect(console.warn).toHaveBeenCalled()
255 | })
256 |
257 | it('should handle appendFileSync errors', () => {
258 | (fs.existsSync as Mock).mockReturnValue(true);
259 | (fs.readFileSync as Mock).mockReturnValue('node_modules\n');
260 | (fs.appendFileSync as Mock).mockImplementation(() => {
261 | throw new Error('Append error')
262 | })
263 |
264 | writeGitignore('.classy')
265 |
266 | expect(console.warn).toHaveBeenCalled()
267 | })
268 | })
269 |
270 | describe('isInIgnoredDirectory', () => {
271 | it('should return true for files in ignored directories', () => {
272 | (path.relative as Mock).mockReturnValue('node_modules/some/file.js')
273 |
274 | const result = isInIgnoredDirectory('/some/path', ['node_modules'])
275 |
276 | expect(result).toBe(true)
277 | })
278 |
279 | it('should return false for files not in ignored directories', () => {
280 | (path.relative as Mock).mockReturnValue('src/components/Button.vue')
281 |
282 | const result = isInIgnoredDirectory('/some/path', ['node_modules'])
283 |
284 | expect(result).toBe(false)
285 | })
286 |
287 | it('should return false when ignoredDirectories is empty', () => {
288 | const result = isInIgnoredDirectory('/some/path', [])
289 |
290 | expect(result).toBe(false)
291 | })
292 |
293 | it('should handle exact directory matches', () => {
294 | (path.relative as Mock).mockReturnValue('node_modules')
295 |
296 | const result = isInIgnoredDirectory('/some/path', ['node_modules'])
297 |
298 | expect(result).toBe(true)
299 | })
300 |
301 | it('should handle multiple ignored directories', () => {
302 | (path.relative as Mock).mockReturnValue('dist/build/file.js')
303 |
304 | const result = isInIgnoredDirectory('/some/path', ['node_modules', 'dist'])
305 |
306 | expect(result).toBe(true)
307 | })
308 |
309 | it('should handle nested ignored directories', () => {
310 | (path.relative as Mock).mockReturnValue('node_modules/lodash/dist/lodash.js')
311 |
312 | const result = isInIgnoredDirectory('/some/path', ['node_modules'])
313 |
314 | expect(result).toBe(true)
315 | })
316 | })
317 |
318 | describe('writeOutputFileDirect', () => {
319 | beforeEach(() => {
320 | vi.clearAllMocks();
321 | (fs.writeFileSync as Mock).mockImplementation(() => {});
322 | (fs.mkdirSync as Mock).mockImplementation(() => {});
323 | (fs.renameSync as Mock).mockImplementation(() => {});
324 | (fs.appendFileSync as Mock).mockImplementation(() => {});
325 | (fs.existsSync as Mock).mockReturnValue(false)
326 | })
327 |
328 | it('should write classes to output file', () => {
329 | const mockClasses = new Set(['hover:bg-blue-500', 'focus:outline-none']);
330 | (fs.existsSync as Mock).mockReturnValue(false)
331 |
332 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
333 |
334 | // Check if directory was created
335 | expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/cwd/.classy', {
336 | recursive: true,
337 | })
338 |
339 | // Check if .gitignore was written in the .classy directory
340 | expect(fs.writeFileSync).toHaveBeenCalledWith(
341 | '/mock/cwd/.classy/.gitignore',
342 | expect.stringContaining('Ignore all files'),
343 | )
344 |
345 | // The function should write the output file (at least the .classy/.gitignore and temp file)
346 | expect(fs.writeFileSync).toHaveBeenCalledTimes(3)
347 | expect(fs.renameSync).toHaveBeenCalledWith(
348 | '/mock/cwd/.classy/.output.html.tmp',
349 | '/mock/cwd/.classy/output.html',
350 | )
351 | })
352 |
353 | it('should skip write if no classes and file exists', () => {
354 | const mockClasses = new Set([]);
355 | // Mock existsSync to return true for the output file but false for other paths
356 | (fs.existsSync as Mock).mockImplementation((path) => {
357 | if (path.includes('output.html')) return true
358 | return false
359 | })
360 |
361 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
362 |
363 | // When no classes and file exists, function returns early without calling writeGitignore
364 | expect(fs.writeFileSync).toHaveBeenCalledTimes(0)
365 | expect(fs.renameSync).not.toHaveBeenCalled()
366 | })
367 |
368 | it('should handle errors gracefully', () => {
369 | const mockClasses = new Set(['hover:bg-blue-500']);
370 |
371 | // Mock existsSync to avoid early return
372 | (fs.existsSync as Mock).mockReturnValue(false);
373 |
374 | // Mock mkdirSync to throw an error
375 | (fs.mkdirSync as Mock).mockImplementation(() => {
376 | throw new Error('Test error')
377 | })
378 |
379 | // Create a spy specifically for console.error
380 | const errorSpy = vi.spyOn(console, 'error')
381 |
382 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
383 |
384 | expect(errorSpy).toHaveBeenCalled()
385 | })
386 |
387 | it('should handle writeFileSync errors', () => {
388 | const mockClasses = new Set(['hover:bg-blue-500']);
389 | (fs.existsSync as Mock).mockReturnValue(false);
390 | (fs.writeFileSync as Mock).mockImplementation((path) => {
391 | if (path.includes('.tmp')) {
392 | throw new Error('Write error')
393 | }
394 | })
395 |
396 | const errorSpy = vi.spyOn(console, 'error')
397 |
398 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
399 |
400 | expect(errorSpy).toHaveBeenCalled()
401 | })
402 |
403 | it('should handle renameSync errors', () => {
404 | const mockClasses = new Set(['hover:bg-blue-500']);
405 | (fs.existsSync as Mock).mockReturnValue(false);
406 | (fs.renameSync as Mock).mockImplementation(() => {
407 | throw new Error('Rename error')
408 | })
409 |
410 | const errorSpy = vi.spyOn(console, 'error')
411 |
412 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
413 |
414 | expect(errorSpy).toHaveBeenCalled()
415 | })
416 |
417 | it('should filter out classes without modifiers', () => {
418 | const mockClasses = new Set(['hover:bg-blue-500', 'flex', 'p-4']);
419 | (fs.existsSync as Mock).mockReturnValue(false)
420 |
421 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
422 |
423 | // Should write both .gitignore and output file (only classes with modifiers)
424 | expect(fs.writeFileSync).toHaveBeenCalledTimes(3)
425 | expect(fs.renameSync).toHaveBeenCalled()
426 | })
427 |
428 | it('should handle empty class set', () => {
429 | const mockClasses = new Set([]);
430 | (fs.existsSync as Mock).mockReturnValue(false)
431 |
432 | writeOutputFileDirect(mockClasses, '.classy', 'output.html')
433 |
434 | // Should still create directory and .gitignore
435 | expect(fs.mkdirSync).toHaveBeenCalled()
436 | expect(fs.writeFileSync).toHaveBeenCalledWith(
437 | '/mock/cwd/.classy/.gitignore',
438 | expect.any(String),
439 | )
440 | })
441 | })
442 |
443 | describe('shouldProcessFile', () => {
444 | it('should return true for supported file types that aren\'t ignored', () => {
445 | (path.relative as Mock).mockReturnValue('src/components/Button.vue')
446 |
447 | const result = shouldProcessFile('src/components/Button.vue', [
448 | 'node_modules',
449 | 'dist',
450 | ])
451 |
452 | expect(result).toBe(true)
453 | })
454 |
455 | it('should return false for files in ignored directories', () => {
456 | (path.relative as Mock).mockReturnValue('node_modules/some/file.js')
457 |
458 | const result = shouldProcessFile('node_modules/some/file.js', [
459 | 'node_modules',
460 | ])
461 |
462 | expect(result).toBe(false)
463 | })
464 |
465 | it('should return false for unsupported file types', () => {
466 | const result = shouldProcessFile('src/styles.css', ['node_modules'])
467 |
468 | expect(result).toBe(false)
469 | })
470 |
471 | it('should return false for virtual files', () => {
472 | const result = shouldProcessFile('virtual:some-module.js', [
473 | 'node_modules',
474 | ])
475 |
476 | expect(result).toBe(false)
477 | })
478 |
479 | it('should return false for files with null bytes', () => {
480 | const result = shouldProcessFile('file\0.js', ['node_modules'])
481 |
482 | expect(result).toBe(false)
483 | })
484 |
485 | it('should return false for runtime files', () => {
486 | const result = shouldProcessFile('runtime-file.js', ['node_modules'])
487 |
488 | expect(result).toBe(false)
489 | })
490 |
491 | it('should return false for files in output directory', () => {
492 | const result = shouldProcessFile('.classy/output.html', ['node_modules'])
493 |
494 | expect(result).toBe(false)
495 | })
496 |
497 | it('should handle null/undefined filePath', () => {
498 | const result = shouldProcessFile(null as unknown as string, ['node_modules'])
499 |
500 | expect(result).toBe(false)
501 | })
502 |
503 | it('should handle empty filePath', () => {
504 | const result = shouldProcessFile('', ['node_modules'])
505 |
506 | expect(result).toBe(false)
507 | })
508 |
509 | it('should handle filePath with only extension', () => {
510 | const result = shouldProcessFile('.vue', ['node_modules'])
511 |
512 | expect(result).toBe(false)
513 | })
514 |
515 | it('should handle filePath with multiple dots', () => {
516 | const result = shouldProcessFile('component.test.vue', ['node_modules'])
517 |
518 | expect(result).toBe(false)
519 | })
520 | })
521 | })
522 |
--------------------------------------------------------------------------------